updates on the admin and zone things and minor changes

This commit is contained in:
2026-10-06 19:21:06 +05:30
parent 6e73b59ca9
commit 7aca6a6a48
14 changed files with 589 additions and 90 deletions

View File

@@ -1,5 +1,11 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { configure, render, screen, fireEvent, waitFor } from '@testing-library/react';
// The page renders the client list and the city query before anything can be
// found. Under the full suite (66 files at once) the first render here was
// regularly slower than the 1 s default, failing "shows Edit and Delete on
// each row" on timing alone.
configure({ asyncUtilTimeout: 5000 });
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
@@ -35,9 +41,35 @@ jest.mock('@/api/doormile/notify', () => ({
let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
// The real address search calls the geocoding service. This stand-in is a text
// box plus a "Pick" button that selects a fixed place, the way picking a
// suggestion does.
const mockPickedPlace = {
formatted_address: '14 DB Road, RS Puram, Coimbatore',
latitude: 11.009,
longitude: 76.95,
city: 'Coimbatore',
postcode: '641002',
address_components: [{ types: ['administrative_area_level_1'], long_name: 'Tamil Nadu' }],
};
jest.mock('@/components/doormile/AddressAutocomplete', () => ({
__esModule: true,
default: ({ id, label, value, onChange, onPlaceSelected }) => (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} value={value} onChange={(e) => onChange(e.target.value)} />
<button type="button" onClick={() => onPlaceSelected(mockPickedPlace)}>
Pick {label}
</button>
</div>
),
}));
import * as api from '@/api/doormile';
import ClientOnboarding from '@/pages/doormile/clients/ClientOnboarding';
import { canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding } from '@/lib/clientOnboarding';
import {
addressFromPlace, canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateAddress, validateOnboarding,
} from '@/lib/clientOnboarding';
const CITIES = [
{ applocationid: 1, applocationname: 'Coimbatore', status: 'Active' },
@@ -67,6 +99,7 @@ const fillValidForm = async () => {
fill('Operating city', '1');
fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } });
fill('Confirm password', 's3cure-pass');
fireEvent.click(screen.getAllByRole('button', { name: 'Pick Client address' })[0]);
};
beforeEach(() => {
@@ -99,6 +132,24 @@ describe('Edit and Delete on a client login', () => {
await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { phone: '9123456780', status: 'Inactive' }));
});
it('adds an address for a client that has none, as one location object', async () => {
renderPage();
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
await screen.findByRole('dialog');
expect(screen.getByText(/No address on file yet/)).toBeInTheDocument();
const picks = screen.getAllByRole('button', { name: 'Pick Client address' });
fireEvent.click(picks[picks.length - 1]); // the dialog's address box
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
await waitFor(() =>
expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, {
location: {
address: '14 DB Road, RS Puram, Coimbatore', city: 'Coimbatore', state: 'Tamil Nadu',
pincode: '641002', latitude: 11.009, longitude: 76.95,
},
})
);
});
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' }));
@@ -171,6 +222,12 @@ describe('Client onboarding page', () => {
password: 's3cure-pass',
applocationid: 1,
requiredeliveryotp: false,
address: '14 DB Road, RS Puram, Coimbatore',
city: 'Coimbatore',
state: 'Tamil Nadu',
pincode: '641002',
latitude: 11.009,
longitude: 76.95,
});
expect(screen.getByRole('status')).toHaveTextContent('ops@acme.example');
expect(screen.getByRole('status')).toHaveTextContent('only time the password is shown');
@@ -198,6 +255,32 @@ describe('Client onboarding page', () => {
expect(api.onboardClient).not.toHaveBeenCalled();
});
it('needs an address with a map location before calling the server', async () => {
renderPage();
await screen.findByRole('option', { name: 'Coimbatore' });
fill('Company name', 'Acme Foods');
fill('Contact person', 'Priya Raman');
fill('Mobile number', '9876543210');
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');
// Typed, never picked: no map location.
fireEvent.change(document.getElementById('onboard-address'), { target: { value: '14 DB Road, RS Puram' } });
fill('Pincode', '641002');
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
expect(await screen.findByText('Pick the address from the suggestions so it has a map location')).toBeInTheDocument();
expect(api.onboardClient).not.toHaveBeenCalled();
});
it('shows each client address in the list', async () => {
api.getOnboardedClients.mockResolvedValue([
{ authid: 32, tenantid: 13, tenantname: 'With Address', primarycontact: '9000000001', status: 'Active', loginemail: 'b@x.example', address: '5 Avinashi Road', pincode: '641004' },
]);
renderPage();
expect(await screen.findByText('5 Avinashi Road · 641004')).toBeInTheDocument();
});
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();
@@ -277,6 +360,7 @@ describe('client onboarding helpers', () => {
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,
address: '14 DB Road, RS Puram', pincode: '641002', latitude: 11.009, longitude: 76.95,
});
expect(errors).toEqual({});
}
@@ -296,4 +380,32 @@ describe('client onboarding helpers', () => {
expect(validateOnboarding({ ...base, password: 'a@b.co', confirm: 'a@b.co' }).password).toBeTruthy();
expect(validateOnboarding({ ...base, password: 'short', confirm: 'short' }).password).toBeTruthy();
});
it('checks the address like the server does', () => {
const ok = { address: '14 DB Road, RS Puram', pincode: '641002', latitude: 11.009, longitude: 76.95 };
expect(validateAddress(ok)).toEqual({});
expect(validateAddress({ ...ok, address: 'abc' }).address).toMatch(/Enter the client/);
expect(validateAddress({ ...ok, pincode: '64100' }).pincode).toBeTruthy();
expect(validateAddress({ ...ok, pincode: '041002' }).pincode).toBeTruthy();
expect(validateAddress({ ...ok, latitude: '', longitude: '' }).address).toMatch(/Pick the address/);
expect(validateAddress({ ...ok, latitude: 76.95, longitude: 11.0 }).address).toMatch(/Pick the address/); // swapped
});
it('reads a picked place from either shape', () => {
expect(addressFromPlace({ formatted_address: 'X Road', latitude: 11, longitude: 77, city: 'Coimbatore', postcode: '641 002' }))
.toEqual({ address: 'X Road', city: 'Coimbatore', pincode: '641002', latitude: 11, longitude: 77 });
const google = {
name: 'Y Street',
geometry: { location: { lat: () => 12.9, lng: () => 77.6 } },
address_components: [
{ types: ['locality'], long_name: 'Bengaluru' },
{ types: ['administrative_area_level_1'], long_name: 'Karnataka' },
{ types: ['postal_code'], long_name: '560034' },
],
};
expect(addressFromPlace(google)).toEqual({
address: 'Y Street', city: 'Bengaluru', state: 'Karnataka', pincode: '560034', latitude: 12.9, longitude: 77.6,
});
expect(addressFromPlace(null)).toEqual({});
});
});

View File

@@ -65,7 +65,7 @@ describe('ZoneContext', () => {
it('should not load tenant locations for a staff login', () => {
setup({ user: { tenantid: '0' } });
expect(useHubs).toHaveBeenCalledWith({ enabled: true });
expect(useHubs).toHaveBeenCalled();
expect(useTenantLocations).toHaveBeenCalledWith(null, { enabled: false });
});
});
@@ -79,17 +79,40 @@ describe('ZoneContext', () => {
longitude: 77.6408
};
it('should offer the tenant kitchen hubs instead of global hubs', () => {
// A client sees their own locations first, then the Doormile hubs. A newly
// onboarded client used to have no locations and therefore no zones at all
// — not even the Coimbatore hub their orders run through.
it('should offer the tenant own locations and then the Doormile hubs', () => {
const { result } = setup({ user: { tenantid: '4' }, locations: [LOCATION] });
expect(result.current.isTenantUser).toBe(true);
expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Indiranagar Kitchen']);
expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Indiranagar Kitchen', 'Koramangala Hub']);
expect(result.current.zones[1].hubtype).toBe('kitchen_hub');
expect(result.current.zones[1].isTenantLocation).toBe(true);
expect(result.current.zones[2].isTenantLocation).toBeFalsy();
});
it('should prefix a location zone id so it cannot collide with a hub id', () => {
const { result } = setup({ user: { tenantid: '4' }, locations: [{ ...LOCATION, tenantlocationid: 2 }] });
expect(result.current.zones.map((z) => z.hubid)).toEqual(['all', 'loc:2', '2']);
expect(result.current.zones[1].locationid).toBe('2');
});
it('should offer only the hubs of the client own city when the login carries it', () => {
const CBE = { ...KORAMANGALA, hubid: '7', hubname: 'Coimbatore Neptune Hub', applocationid: 1 };
const BLR = { ...KORAMANGALA, applocationid: 3 };
const { result } = setup({ user: { tenantid: '4', applocationid: 1 }, hubs: [CBE, BLR] });
expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Coimbatore Neptune Hub']);
});
it('should still show the hubs to a client with no locations yet', () => {
const { result } = setup({ user: { tenantid: '4' }, locations: [] });
expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Koramangala Hub']);
});
it('should scope the location query to the operator own tenant', () => {
setup({ user: { tenantid: '4' }, locations: [LOCATION] });
expect(useTenantLocations).toHaveBeenCalledWith('4', { enabled: true });
expect(useHubs).toHaveBeenCalledWith({ enabled: false });
expect(useHubs).toHaveBeenCalled();
});
it('should treat tenantid "0" as staff, not as a tenant', () => {
@@ -425,7 +448,7 @@ describe('ZoneContext', () => {
expect(result.current.isHubsLoading).toBe(true);
});
it('should refetch the source the current login actually reads', () => {
it('should refetch both sources a client login reads', () => {
const refetchHubs = jest.fn();
const refetchLocations = jest.fn();
useAuth.mockReturnValue({ user: { tenantid: '4' } });
@@ -436,7 +459,7 @@ describe('ZoneContext', () => {
act(() => result.current.refetchHubs());
expect(refetchLocations).toHaveBeenCalledTimes(1);
expect(refetchHubs).not.toHaveBeenCalled();
expect(refetchHubs).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,51 @@
import { CLIENT_FLEET_OPS_PATHS, CLIENT_ONBOARDING_PATH, navGroupsFor } from '@/lib/consoleNav';
import { canOnboardClients } from '@/lib/clientOnboarding';
const GROUPS = [
{
label: 'Fleet Ops',
items: [
{ label: 'Hubs', path: '/doormile/hubs' },
{ label: 'Vehicles', path: '/doormile/vehicles' },
{ label: 'Tripsheets', path: '/doormile/tripsheets' },
{ label: 'Exceptions', path: '/doormile/exceptions' },
{ label: 'Returns', path: '/doormile/returns' },
{ label: 'Competitive Intel', path: '/doormile/competitive-intel' },
{ label: 'App Users', path: '/doormile/app-users' },
{ label: 'Agents', path: '/doormile/agents' },
],
},
{ label: 'Reports', items: [{ label: 'Orders Summary', path: '/doormile/reports/orderssummary' }] },
];
const ONBOARD = { label: 'Client Onboarding', path: CLIENT_ONBOARDING_PATH };
const labelsOf = (groups, name) => groups.find((g) => g.label === name).items.map((i) => i.label);
describe('header menu per login', () => {
test('a client sees Fleet Ops with only Hubs, Exceptions and Returns', () => {
const groups = navGroupsFor(GROUPS, { isClient: true, canOnboard: false, onboardingItem: ONBOARD });
expect(labelsOf(groups, 'Fleet Ops')).toEqual(['Hubs', 'Exceptions', 'Returns']);
expect(labelsOf(groups, 'Reports')).toEqual(['Orders Summary']);
});
test('a client never sees Agents or Client Onboarding, even if asked to', () => {
const groups = navGroupsFor(GROUPS, { isClient: true, canOnboard: true, onboardingItem: ONBOARD });
const all = groups.flatMap((g) => g.items.map((i) => i.path));
expect(all).not.toContain('/doormile/agents');
expect(all).not.toContain(CLIENT_ONBOARDING_PATH);
expect(all.filter((p) => p.startsWith('/doormile/') && !p.includes('/reports/'))).toEqual(CLIENT_FLEET_OPS_PATHS);
});
test('the onboarding owner sees the full Fleet Ops menu plus Client Onboarding', () => {
const owner = { email: 'admin@doormile.com', role: 'admin' };
const groups = navGroupsFor(GROUPS, { isClient: false, canOnboard: canOnboardClients(owner, false), onboardingItem: ONBOARD });
expect(labelsOf(groups, 'Fleet Ops')).toEqual([...GROUPS[0].items.map((i) => i.label), 'Client Onboarding']);
});
test('other Doormile staff see Fleet Ops and Agents but not Client Onboarding', () => {
const staff = { email: 'ops@doormile.com', role: 'admin' };
const groups = navGroupsFor(GROUPS, { isClient: false, canOnboard: canOnboardClients(staff, false), onboardingItem: ONBOARD });
expect(labelsOf(groups, 'Fleet Ops')).toContain('Agents');
expect(labelsOf(groups, 'Fleet Ops')).not.toContain('Client Onboarding');
});
});

View File

@@ -87,8 +87,10 @@ describe('a tenant session never matches on a hub id', () => {
it('a booking whose pickuphubid is 5 does not land in tenant location 5', () => {
// Two different id spaces. Matching them would file an order under a
// kitchen it has nothing to do with, on a bare numeric collision.
// A client location's zone id is prefixed ('loc:5') precisely so it can
// never be confused with hub 5, which a client now also sees.
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
expect(inZone(result, '5', {
expect(inZone(result, 'loc:5', {
bookingid: 3, bookingno: 'DM-X', tenantid: '13', pickuphubid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(false);
@@ -96,11 +98,28 @@ describe('a tenant session never matches on a hub id', () => {
it('but its own tenantlocationid still matches', () => {
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
expect(inZone(result, '5', {
expect(inZone(result, 'loc:5', {
bookingid: 4, bookingno: 'DM-Y', tenantid: '13', tenantlocationid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(true);
});
it('a client who picks a Doormile HUB is matched on hub ids, never on location ids', () => {
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
expect(inZone(result, '5', {
bookingid: 5, bookingno: 'DM-H', tenantid: '13', pickuphubid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(true);
expect(inZone(result, '5', {
bookingid: 6, bookingno: 'DM-L', tenantid: '13', tenantlocationid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(false);
});
it("never shows a client another tenant's order, whatever zone is picked", () => {
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
expect(inZone(result, '5', { bookingid: 7, tenantid: '99', pickuphubid: 5, pickupaddress: 'a' })).toBe(false);
});
});
describe('the city comparison, which needs a city on the hub', () => {
@@ -172,9 +191,18 @@ describe('one id space at a time', () => {
const KITCHEN = { tenantlocationid: 5, locationname: 'Indiranagar Kitchen',
city: 'Bengaluru', latitude: 12.9784, longitude: 77.6408, pincode: '560038' };
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
expect(inZone(result, '5', { bookingid: 1, tenantid: '13', tenantlocationid: 5,
expect(inZone(result, 'loc:5', { bookingid: 1, tenantid: '13', tenantlocationid: 5,
pickupaddress: 'a', deliveryaddress: 'b' })).toBe(true);
expect(inZone(result, '5', { bookingid: 2, tenantid: '13', servicinghubid: 5,
expect(inZone(result, 'loc:5', { bookingid: 2, tenantid: '13', servicinghubid: 5,
pickupaddress: 'a', deliveryaddress: 'b' })).toBe(false);
});
it('a deactivated tenant location is not offered as a zone', () => {
const LIVE = { tenantlocationid: 7, locationname: 'Saibaba Colony', status: 'Active' };
const OLD = { tenantlocationid: 8, locationname: 'Main Branch', status: 'Inactive' };
const { result } = setup({ user: { tenantid: '13' }, locations: [LIVE, OLD] });
const ids = result.current.zones.map((z) => z.hubid);
expect(ids).toContain('loc:7');
expect(ids).not.toContain('loc:8');
});
});