updated the test

This commit is contained in:
2026-08-27 13:40:48 +05:30
parent e34e41ca96
commit 8855b62b39
25 changed files with 9239 additions and 2 deletions

View File

@@ -0,0 +1,282 @@
import { renderHook, act, waitFor } from '@testing-library/react';
jest.mock('@/api/doormile', () => ({
__esModule: true,
DOORMILE_TOKEN_KEY: 'doormileToken',
DOORMILE_USER_KEY: 'doormileUser',
loginAdmin: jest.fn(),
logoutAdmin: jest.fn(),
readStoredToken: jest.fn(),
readStoredUser: jest.fn()
}));
import { AuthProvider, useAuth } from '@/lib/AuthContext';
import { loginAdmin, logoutAdmin, readStoredToken, readStoredUser } from '@/api/doormile';
const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>;
const renderAuth = () => renderHook(() => useAuth(), { wrapper });
/**
* Session state for the console shell.
*
* What these tests protect is the authorization gate itself: who is considered
* signed in, what a refused credential does (show the server's wording, do NOT
* throw), and that a sign-out in one tab ends the session in every other tab
* rather than leaving a rendered console around a token that is gone.
*/
describe('AuthContext', () => {
let replace;
beforeEach(() => {
replace = jest.fn();
Object.defineProperty(window, 'location', {
writable: true,
configurable: true,
value: { href: 'http://console.local/deliveries', replace }
});
readStoredToken.mockReturnValue(null);
readStoredUser.mockReturnValue(null);
});
describe('boot', () => {
it('should report a signed-out shell when no token is stored', async () => {
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
expect(result.current.isLoadingAuth).toBe(false);
});
it('should restore the signed-in admin from storage', async () => {
readStoredToken.mockReturnValue('jwt-abc');
readStoredUser.mockReturnValue({ userid: 7, tenantid: '0' });
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toEqual({ userid: 7, tenantid: '0' });
});
it('should treat the token as the credential — no token means no user', async () => {
// A stale user record without a token must not produce a rendered console
// that 401s on every fetch.
readStoredToken.mockReturnValue(null);
readStoredUser.mockReturnValue({ userid: 7 });
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it('should mark the auth check complete exactly once on mount', async () => {
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
expect(readStoredToken).toHaveBeenCalledTimes(1);
});
});
describe('login', () => {
it('should sign the operator in on a successful credential', async () => {
loginAdmin.mockResolvedValue({ success: true, user: { userid: 7 } });
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
let outcome;
await act(async () => {
outcome = await result.current.login('ops@doormile.com', 'hunter2');
});
expect(loginAdmin).toHaveBeenCalledWith('ops@doormile.com', 'hunter2');
expect(outcome).toEqual({ success: true });
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toEqual({ userid: 7 });
});
it('should fall back to the stored record when the response omits the user', async () => {
loginAdmin.mockResolvedValue({ success: true });
readStoredUser.mockReturnValue({ userid: 9 });
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
await act(async () => {
await result.current.login('ops@doormile.com', 'hunter2');
});
expect(result.current.user).toEqual({ userid: 9 });
});
it("should surface the server's own wording for a refused credential", async () => {
// Resolving rather than throwing is deliberate: the form shows the
// server's message instead of a generic failure.
loginAdmin.mockResolvedValue({ success: false, message: 'Account is locked' });
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
let outcome;
await act(async () => {
outcome = await result.current.login('ops@doormile.com', 'wrong');
});
expect(outcome).toEqual({ success: false, message: 'Account is locked' });
expect(result.current.authError).toBe('Account is locked');
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it('should use a default message when the server refuses without one', async () => {
loginAdmin.mockResolvedValue({ success: false });
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
let outcome;
await act(async () => {
outcome = await result.current.login('a@b.c', 'x');
});
expect(outcome.success).toBe(false);
expect(outcome.message).toBe('Those credentials were not accepted');
});
it('should report a transport failure without throwing at the form', async () => {
loginAdmin.mockRejectedValue({ message: 'Network Error' });
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
let outcome;
await act(async () => {
outcome = await result.current.login('a@b.c', 'x');
});
expect(outcome).toEqual({ success: false, message: 'Network Error' });
expect(result.current.isAuthenticated).toBe(false);
});
it('should report a reachability message when the rejection carries none', async () => {
loginAdmin.mockRejectedValue({});
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
let outcome;
await act(async () => {
outcome = await result.current.login('a@b.c', 'x');
});
expect(outcome.message).toBe('Could not reach the Doormile API');
});
it('should clear a previous error when a retry succeeds', async () => {
loginAdmin.mockResolvedValueOnce({ success: false, message: 'Invalid credentials' });
loginAdmin.mockResolvedValueOnce({ success: true, user: { userid: 7 } });
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
await act(async () => {
await result.current.login('a@b.c', 'wrong');
});
expect(result.current.authError).toBe('Invalid credentials');
await act(async () => {
await result.current.login('a@b.c', 'right');
});
expect(result.current.authError).toBeNull();
});
});
describe('logout', () => {
it('should clear the stored session and send the operator to login', async () => {
readStoredToken.mockReturnValue('jwt-abc');
readStoredUser.mockReturnValue({ userid: 7 });
const { result } = renderAuth();
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
act(() => {
result.current.logout();
});
expect(logoutAdmin).toHaveBeenCalledTimes(1);
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
expect(replace).toHaveBeenCalledWith('/login');
});
it('should still clear state when the caller suppresses the redirect', async () => {
readStoredToken.mockReturnValue('jwt-abc');
const { result } = renderAuth();
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
act(() => {
result.current.logout(false);
});
expect(logoutAdmin).toHaveBeenCalledTimes(1);
expect(result.current.isAuthenticated).toBe(false);
expect(replace).not.toHaveBeenCalled();
});
});
describe('cross-tab session sync', () => {
it('should end this tab session when the token disappears in another tab', async () => {
readStoredToken.mockReturnValue('jwt-abc');
readStoredUser.mockReturnValue({ userid: 7 });
const { result } = renderAuth();
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
readStoredToken.mockReturnValue(null);
readStoredUser.mockReturnValue(null);
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: 'doormileToken' }));
});
await waitFor(() => expect(result.current.isAuthenticated).toBe(false));
});
it('should pick up a sign-IN that happened in another tab', async () => {
const { result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
readStoredToken.mockReturnValue('jwt-abc');
readStoredUser.mockReturnValue({ userid: 7 });
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: 'doormileUser' }));
});
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
});
it('should ignore an unrelated storage key', async () => {
readStoredToken.mockReturnValue('jwt-abc');
const { result } = renderAuth();
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
readStoredToken.mockClear();
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: 'doormilePreferences' }));
});
expect(readStoredToken).not.toHaveBeenCalled();
expect(result.current.isAuthenticated).toBe(true);
});
it('should stop listening once unmounted', async () => {
const removeSpy = jest.spyOn(window, 'removeEventListener');
const { unmount, result } = renderAuth();
await waitFor(() => expect(result.current.authChecked).toBe(true));
unmount();
expect(removeSpy).toHaveBeenCalledWith('storage', expect.any(Function));
});
});
describe('useAuth outside a provider', () => {
it('should throw a named error rather than returning undefined', () => {
// Silent undefined here would render an unauthenticated shell as if it
// were authorized.
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => renderHook(() => useAuth())).toThrow('useAuth must be used within an AuthProvider');
spy.mockRestore();
});
});
});

View File

@@ -0,0 +1,401 @@
import { renderHook, act } from '@testing-library/react';
jest.mock('@/lib/doormileHooks', () => ({
__esModule: true,
useHubs: jest.fn(),
useTenantLocations: jest.fn()
}));
jest.mock('@/lib/AuthContext', () => ({ __esModule: true, useAuth: jest.fn() }));
import { ZoneProvider, useZone } from '@/lib/ZoneContext';
import { useHubs, useTenantLocations } from '@/lib/doormileHooks';
import { useAuth } from '@/lib/AuthContext';
const ZONE_STORAGE_KEY = 'doormile_active_zone_id';
const KORAMANGALA = {
hubid: '2',
hubname: 'Koramangala Hub',
city: 'Bengaluru',
pincode: '560034',
latitude: 12.9352,
longitude: 77.6245,
status: 'Active'
};
const wrapper = ({ children }) => <ZoneProvider>{children}</ZoneProvider>;
const setup = ({ user = { tenantid: '0' }, hubs = [KORAMANGALA], locations = [] } = {}) => {
useAuth.mockReturnValue({ user });
useHubs.mockReturnValue({ data: hubs, isLoading: false, refetch: jest.fn() });
useTenantLocations.mockReturnValue({ data: locations, isLoading: false, refetch: jest.fn() });
return renderHook(() => useZone(), { wrapper });
};
/**
* Zone scoping — which hub or kitchen an operator is currently looking at, and
* which rows belong to it.
*
* This is a data-visibility boundary, not just a filter: on a tenant login it
* decides whether another tenant's orders can appear on screen at all. The
* tenant check therefore has its own tests, separate from the hub matching.
*/
describe('ZoneContext', () => {
describe('zone list — staff login', () => {
it('should offer All Zones plus every active hub', () => {
const { result } = setup({ hubs: [KORAMANGALA, { ...KORAMANGALA, hubid: '3', hubname: 'HSR Hub' }] });
expect(result.current.zones.map((z) => z.hubid)).toEqual(['all', '2', '3']);
});
it('should hide an inactive hub', () => {
const { result } = setup({ hubs: [KORAMANGALA, { ...KORAMANGALA, hubid: '9', status: 'Inactive' }] });
expect(result.current.zones.map((z) => z.hubid)).toEqual(['all', '2']);
});
it('should treat a hub with no status as active', () => {
const { result } = setup({ hubs: [{ ...KORAMANGALA, status: undefined }] });
expect(result.current.zones).toHaveLength(2);
});
it('should still offer All Zones when there are no hubs at all', () => {
const { result } = setup({ hubs: [] });
expect(result.current.zones).toHaveLength(1);
expect(result.current.isAllZones).toBe(true);
});
it('should not load tenant locations for a staff login', () => {
setup({ user: { tenantid: '0' } });
expect(useHubs).toHaveBeenCalledWith({ enabled: true });
expect(useTenantLocations).toHaveBeenCalledWith(null, { enabled: false });
});
});
describe('zone list — tenant login', () => {
const LOCATION = {
tenantlocationid: 55,
locationname: 'Indiranagar Kitchen',
city: 'Bengaluru',
latitude: 12.9784,
longitude: 77.6408
};
it('should offer the tenant kitchen hubs instead of global 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[1].hubtype).toBe('kitchen_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 });
});
it('should treat tenantid "0" as staff, not as a tenant', () => {
const { result } = setup({ user: { tenantid: '0' } });
expect(result.current.isTenantUser).toBe(false);
});
it('should fall back to the stored tenantid when the user record has none', () => {
localStorage.setItem('tenantid', '4');
const { result } = setup({ user: {}, locations: [LOCATION] });
expect(result.current.isTenantUser).toBe(true);
expect(result.current.tenantId).toBe('4');
});
it('should label an unnamed kitchen hub by its id', () => {
const { result } = setup({
user: { tenantid: '4' },
locations: [{ tenantlocationid: 55 }]
});
expect(result.current.zones[1].hubname).toBe('Kitchen Hub #55');
});
});
describe('zone selection', () => {
it('should default to All Zones', () => {
const { result } = setup();
expect(result.current.selectedZoneId).toBe('all');
expect(result.current.isAllZones).toBe(true);
});
it('should restore the previously selected zone from storage', () => {
localStorage.setItem(ZONE_STORAGE_KEY, '2');
const { result } = setup();
expect(result.current.selectedZoneId).toBe('2');
expect(result.current.selectedZone.hubname).toBe('Koramangala Hub');
expect(result.current.isAllZones).toBe(false);
});
it('should persist a new selection', () => {
const { result } = setup();
act(() => result.current.setSelectedZoneId('2'));
expect(localStorage.getItem(ZONE_STORAGE_KEY)).toBe('2');
expect(result.current.selectedZone.hubid).toBe('2');
});
it('should coerce a numeric zone id to a string', () => {
const { result } = setup();
act(() => result.current.setSelectedZoneId(2));
expect(result.current.selectedZoneId).toBe('2');
expect(result.current.selectedZone.hubname).toBe('Koramangala Hub');
});
it.each([null, undefined, ''])('should fall back to All Zones for the selection %p', (id) => {
const { result } = setup();
act(() => result.current.setSelectedZoneId(id));
expect(result.current.selectedZoneId).toBe('all');
});
it('should fall back to All Zones when the stored zone no longer exists', () => {
// A hub that was deleted or deactivated must not leave the console
// filtering against a zone that matches nothing.
localStorage.setItem(ZONE_STORAGE_KEY, '999');
const { result } = setup();
expect(result.current.selectedZone.hubid).toBe('all');
expect(result.current.isAllZones).toBe(true);
});
it('should survive a storage read failure at boot', () => {
const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled');
});
expect(() => setup()).not.toThrow();
getItem.mockRestore();
});
it('should not throw when persisting is refused', () => {
const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
const { result } = setup();
expect(() => act(() => result.current.setSelectedZoneId('2'))).not.toThrow();
expect(result.current.selectedZoneId).toBe('2');
setItem.mockRestore();
});
});
describe('matchesZone — All Zones', () => {
it('should match any row', () => {
const { result } = setup();
expect(result.current.matchesZone({ hubid: '999' })).toBe(true);
expect(result.current.matchesZone({})).toBe(true);
});
it('should still reject a null row', () => {
const { result } = setup();
expect(result.current.matchesZone(null)).toBe(false);
expect(result.current.matchesZone(undefined)).toBe(false);
});
});
describe('matchesZone — tenant isolation', () => {
it('should reject a row belonging to a different tenant even under All Zones', () => {
// The data-visibility boundary: tenant scoping is checked BEFORE the
// all-zones shortcut, so another tenant's orders can never render.
localStorage.setItem('tenantid', '4');
const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55 }] });
expect(result.current.isAllZones).toBe(true);
expect(result.current.matchesZone({ tenantid: '9' })).toBe(false);
expect(result.current.matchesZone({ tenantid: '4' })).toBe(true);
});
it('should compare tenant ids across number and string forms', () => {
const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55 }] });
expect(result.current.matchesZone({ tenantid: 4 })).toBe(true);
});
it('should not apply tenant scoping to a staff login', () => {
const { result } = setup({ user: { tenantid: '0' } });
expect(result.current.matchesZone({ tenantid: '9' })).toBe(true);
});
it('should allow a row that carries no tenant id at all', () => {
const { result } = setup({ user: { tenantid: '4' }, locations: [{ tenantlocationid: 55 }] });
expect(result.current.matchesZone({ bookingid: 1 })).toBe(true);
});
});
describe('matchesZone — hub matching for orders', () => {
const selectKoramangala = () => {
localStorage.setItem(ZONE_STORAGE_KEY, '2');
return setup();
};
it.each(['hubid', 'sourcehubid', 'applocationid', 'tenantlocationid', 'locationid'])(
'should match on a direct %s',
(field) => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ [field]: '2' })).toBe(true);
}
);
it('should match a numeric hub id against the string zone id', () => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ hubid: 2 })).toBe(true);
});
it('should match on the hub pincode appearing in an address', () => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ deliveryaddress: '4th Block, Bengaluru 560034' })).toBe(true);
});
it('should match on the hub city appearing in an address, case-insensitively', () => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ pickupaddress: '12 MG Road, BENGALURU' })).toBe(true);
});
it('should match on the hub name appearing in an address', () => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ address: 'Near koramangala hub' })).toBe(true);
});
it('should match a pickup coordinate inside the 35 km radius', () => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ pickuplatitude: 12.94, pickuplongitude: 77.62 })).toBe(true);
});
it('should match a drop coordinate inside the radius even when pickup is far', () => {
const { result } = selectKoramangala();
expect(
result.current.matchesZone({
pickuplatitude: 19.076,
pickuplongitude: 72.877,
deliverylatitude: 12.94,
deliverylongitude: 77.62
})
).toBe(true);
});
it('should reject a row in another city with no matching id or coordinate', () => {
const { result } = selectKoramangala();
expect(
result.current.matchesZone({
hubid: '7',
deliveryaddress: 'Bandra West, Mumbai 400050',
deliverylatitude: 19.076,
deliverylongitude: 72.877
})
).toBe(false);
});
it('should reject a coordinate well outside the radius', () => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ latitude: 19.076, longitude: 72.877 })).toBe(false);
});
it('should reject a row with nothing to match on', () => {
const { result } = selectKoramangala();
expect(result.current.matchesZone({ bookingid: 4021 })).toBe(false);
});
it('should ignore an unusable coordinate rather than treating it as origin', () => {
// (0,0) is in the Atlantic; a NaN or null coordinate must not silently
// become a position that is 35 km from nothing.
const { result } = selectKoramangala();
expect(result.current.matchesZone({ pickuplatitude: null, pickuplongitude: null })).toBe(false);
expect(result.current.matchesZone({ pickuplatitude: 'abc', pickuplongitude: 'abc' })).toBe(false);
});
});
describe('matchesZone — riders', () => {
it('should match a rider assigned to the selected hub', () => {
localStorage.setItem(ZONE_STORAGE_KEY, '2');
const { result } = setup();
expect(result.current.matchesZone({ milerprofileid: 11, hubid: '2' })).toBe(true);
});
it('should match a rider by their app location', () => {
localStorage.setItem(ZONE_STORAGE_KEY, '2');
const { result } = setup();
expect(result.current.matchesZone({ milerprofileid: 11, applocationid: '2' })).toBe(true);
});
it('should match a rider by city when they carry no hub id', () => {
localStorage.setItem(ZONE_STORAGE_KEY, '2');
const { result } = setup();
expect(result.current.matchesZone({ milerprofileid: 11, city: 'bengaluru' })).toBe(true);
});
it('should reject a rider from another city', () => {
localStorage.setItem(ZONE_STORAGE_KEY, '2');
const { result } = setup();
expect(result.current.matchesZone({ milerprofileid: 11, city: 'Mumbai' })).toBe(false);
});
it('should show every fleet rider to a tenant, whichever kitchen is selected', () => {
// Riders belong to the tenant fleet and serve all its kitchen hubs.
localStorage.setItem(ZONE_STORAGE_KEY, '55');
const { result } = setup({
user: { tenantid: '4' },
locations: [{ tenantlocationid: 55, city: 'Bengaluru' }]
});
expect(result.current.matchesZone({ milerprofileid: 11, tenantid: '4', city: 'Mysuru' })).toBe(true);
});
it('should still reject a rider belonging to another tenant', () => {
localStorage.setItem(ZONE_STORAGE_KEY, '55');
const { result } = setup({
user: { tenantid: '4' },
locations: [{ tenantlocationid: 55, city: 'Bengaluru' }]
});
expect(result.current.matchesZone({ milerprofileid: 11, tenantid: '9' })).toBe(false);
});
it.each([
['milerprofileid', { milerprofileid: 11 }],
['authname', { authname: 'anita' }],
['userid + availabilitystatus', { userid: 91, availabilitystatus: 'available' }]
])('should recognise a rider row by its %s', (_label, row) => {
localStorage.setItem(ZONE_STORAGE_KEY, '2');
const { result } = setup();
// A rider row with no location signal is rejected as a rider, not
// accidentally address-matched as an order.
expect(result.current.matchesZone(row)).toBe(false);
});
});
describe('loading state and refetch', () => {
it('should surface the hub loading state for a staff login', () => {
useAuth.mockReturnValue({ user: { tenantid: '0' } });
useHubs.mockReturnValue({ data: [], isLoading: true, refetch: jest.fn() });
useTenantLocations.mockReturnValue({ data: [], isLoading: false, refetch: jest.fn() });
const { result } = renderHook(() => useZone(), { wrapper });
expect(result.current.isHubsLoading).toBe(true);
});
it('should surface the location loading state for a tenant login', () => {
useAuth.mockReturnValue({ user: { tenantid: '4' } });
useHubs.mockReturnValue({ data: [], isLoading: false, refetch: jest.fn() });
useTenantLocations.mockReturnValue({ data: [], isLoading: true, refetch: jest.fn() });
const { result } = renderHook(() => useZone(), { wrapper });
expect(result.current.isHubsLoading).toBe(true);
});
it('should refetch the source the current login actually reads', () => {
const refetchHubs = jest.fn();
const refetchLocations = jest.fn();
useAuth.mockReturnValue({ user: { tenantid: '4' } });
useHubs.mockReturnValue({ data: [], isLoading: false, refetch: refetchHubs });
useTenantLocations.mockReturnValue({ data: [], isLoading: false, refetch: refetchLocations });
const { result } = renderHook(() => useZone(), { wrapper });
act(() => result.current.refetchHubs());
expect(refetchLocations).toHaveBeenCalledTimes(1);
expect(refetchHubs).not.toHaveBeenCalled();
});
});
describe('useZone outside a provider', () => {
it('should throw a named error rather than returning undefined', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => renderHook(() => useZone())).toThrow('useZone must be used within a ZoneProvider');
spy.mockRestore();
});
});
});

View File

@@ -0,0 +1,138 @@
import { BATCHES, getBatchForHour, batchRangeLabel, getRowBatchId, getBatchLabel } from '@/lib/batchBucket';
/**
* Morning/Afternoon/Evening wave bucketing.
*
* The bug these tests exist to prevent: the windows used to cover only 14.5 of
* 24 hours, so an order placed at 2:43pm belonged to no batch and vanished from
* every batch filter. The invariant is TOTAL COVERAGE — every hour of the day
* lands in exactly one batch — plus the field the bucketing reads (`orderdate`,
* never `assigntime` or `expecteddeliverytime`).
*/
describe('batchBucket', () => {
describe('BATCHES definition', () => {
it('should define exactly the three operator-facing waves', () => {
expect(BATCHES.map((b) => b.id)).toEqual(['morning', 'afternoon', 'evening']);
});
it('should cover the whole day with no gap and no overlap', () => {
const sorted = [...BATCHES].sort((a, b) => a.startHour - b.startHour);
expect(sorted[0].startHour).toBe(0);
expect(sorted[sorted.length - 1].endHour).toBe(24);
sorted.slice(1).forEach((batch, index) => {
expect(batch.startHour).toBe(sorted[index].endHour);
});
});
it('should assign every quarter-hour of the day to exactly one batch', () => {
for (let hour = 0; hour < 24; hour += 0.25) {
const matches = BATCHES.filter((b) => hour >= b.startHour && hour < b.endHour);
expect(matches).toHaveLength(1);
}
});
});
describe('getBatchForHour', () => {
it.each([
[0, 'morning'],
[8.99, 'morning'],
[9, 'afternoon'],
[14.716, 'afternoon'],
[15.99, 'afternoon'],
[16, 'evening'],
[23.99, 'evening']
])('should bucket hour %p into %s', (hour, expected) => {
expect(getBatchForHour(hour)).toBe(expected);
});
it('should treat every window as half-open [start, end)', () => {
expect(getBatchForHour(9)).toBe('afternoon');
expect(getBatchForHour(8.999999)).toBe('morning');
expect(getBatchForHour(16)).toBe('evening');
expect(getBatchForHour(15.999999)).toBe('afternoon');
});
it.each([-0.1, 24, 25, NaN])('should return null for the out-of-range hour %p', (hour) => {
expect(getBatchForHour(hour)).toBeNull();
});
});
describe('getRowBatchId', () => {
it('should bucket on orderdate — the booking creation time', () => {
expect(getRowBatchId({ orderdate: '2026-08-27T07:15:00' })).toBe('morning');
expect(getRowBatchId({ orderdate: '2026-08-27T10:00:00' })).toBe('afternoon');
expect(getRowBatchId({ orderdate: '2026-08-27T18:30:00' })).toBe('evening');
});
it('should bucket the 2:43pm order that used to fall into the old 12:30-4pm black hole', () => {
expect(getRowBatchId({ orderdate: '2026-08-27T14:43:00' })).toBe('afternoon');
});
it('should use fractional hours so the back half of an hour is not mis-bucketed', () => {
expect(getRowBatchId({ orderdate: '2026-08-27T15:45:00' })).toBe('afternoon');
expect(getRowBatchId({ orderdate: '2026-08-27T16:01:00' })).toBe('evening');
});
it('should strip a false trailing Z rather than shifting the row into another batch', () => {
// 20:00 with a false Z read as a real UTC instant becomes 01:30 the next
// day in IST, which would move an evening order into Morning.
expect(getRowBatchId({ orderdate: '2026-08-27T20:00:00Z' })).toBe('evening');
});
it('should refuse to bucket a bare date, which has no time component', () => {
// Midnight is a real morning-batch hour, so guessing would silently pile
// every date-only row into Morning.
expect(getRowBatchId({ orderdate: '2026-08-27' })).toBeNull();
expect(getRowBatchId({ orderdate: ' 2026-08-27 ' })).toBeNull();
});
it.each([
['a row with no orderdate', {}],
['a null orderdate', { orderdate: null }],
['an empty orderdate', { orderdate: '' }],
['a null row', null],
['an undefined row', undefined]
])('should return null for %s', (_label, row) => {
expect(getRowBatchId(row)).toBeNull();
});
it('should return null for an unparseable orderdate rather than throwing', () => {
expect(getRowBatchId({ orderdate: 'yesterday-ish' })).toBeNull();
});
it('should ignore assigntime and expecteddeliverytime entirely', () => {
// Both were tried and rejected: assigntime is re-stamped by any status
// change, expecteddeliverytime describes the promised slot.
const row = {
orderdate: '2026-08-27T07:00:00',
assigntime: '2026-08-27T18:00:00',
expecteddeliverytime: '2026-08-27T16:30:00'
};
expect(getRowBatchId(row)).toBe('morning');
});
});
describe('labels', () => {
it('should describe the morning and afternoon windows as ranges', () => {
expect(batchRangeLabel(BATCHES[0])).toBe('12 AM–9 AM');
expect(batchRangeLabel(BATCHES[1])).toBe('9 AM–4 PM');
});
it('should describe a window that runs to end of day as "After X"', () => {
expect(batchRangeLabel(BATCHES[2])).toBe('After 4 PM');
});
it('should render a half-hour boundary with minutes', () => {
expect(batchRangeLabel({ startHour: 12.5, endHour: 16 })).toBe('12:30 PM–4 PM');
});
it('should return the human label for a known batch id', () => {
expect(getBatchLabel('morning')).toBe('Morning Batch');
expect(getBatchLabel('evening')).toBe('Evening Batch');
});
it('should fall back to the id itself for an unknown batch', () => {
expect(getBatchLabel('midnight')).toBe('midnight');
});
});
});

179
tests/lib/distance.test.js Normal file
View File

@@ -0,0 +1,179 @@
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
/**
* Distance and charge for a delivery leg.
*
* OSRM is the routed source; the Haversine + 1.3 multiplier is the fallback.
* The behaviour that matters commercially is that the fallback NEVER reports a
* duration — a straight-line estimate has no honest ETA, and showing a made-up
* one to an operator is worse than showing none.
*/
const osrmOk = (distanceMeters, durationSeconds) => ({
ok: true,
json: async () => ({ routes: [{ distance: distanceMeters, duration: durationSeconds }] })
});
const BLR = { latitude: 12.9716, longitude: 77.5946 };
const IND = { latitude: 12.9352, longitude: 77.6245 };
describe('calculateDrivingDistance', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
describe('coordinate validation', () => {
it.each([
['a missing origin latitude', { longitude: 77.5946 }, IND],
['a missing origin longitude', { latitude: 12.9716 }, IND],
['a missing destination latitude', BLR, { longitude: 77.6245 }],
['a missing destination longitude', BLR, { latitude: 12.9352 }],
['a null origin', null, IND],
['an undefined destination', BLR, undefined],
['a null latitude value', { latitude: null, longitude: 77.5946 }, IND]
])('should reject %s', async (_label, origin, destination) => {
await expect(calculateDrivingDistance(origin, destination)).rejects.toThrow('Invalid coordinates');
expect(global.fetch).not.toHaveBeenCalled();
});
it('should accept zero coordinates, which are a real position', async () => {
global.fetch.mockResolvedValue(osrmOk(1000, 120));
await expect(
calculateDrivingDistance({ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 })
).resolves.toEqual(expect.any(Number));
});
});
describe('OSRM routed distance', () => {
it('should return the routed distance rounded to whole kilometres', async () => {
global.fetch.mockResolvedValue(osrmOk(8200, 900));
await expect(calculateDrivingDistance(BLR, IND)).resolves.toBe(8);
});
it('should cache the routed duration in whole minutes', async () => {
global.fetch.mockResolvedValue(osrmOk(8200, 900));
await calculateDrivingDistance(BLR, IND);
expect(getLastRouteDurationMin()).toBe(15);
});
it('should request lon,lat order — the order OSRM expects', async () => {
// Swapping to lat,lon silently returns a route between two other places.
global.fetch.mockResolvedValue(osrmOk(8200, 900));
await calculateDrivingDistance(BLR, IND);
const url = global.fetch.mock.calls[0][0];
expect(url).toContain('/route/v1/driving/77.5946,12.9716;77.6245,12.9352');
expect(url).toContain('overview=false');
});
it('should use the public OSRM host by default', async () => {
global.fetch.mockResolvedValue(osrmOk(8200, 900));
await calculateDrivingDistance(BLR, IND);
expect(global.fetch.mock.calls[0][0]).toContain('https://router.project-osrm.org');
});
it('should honour a VITE_OSRM_URL override for a self-hosted router', async () => {
globalThis.__VITE_IMPORT_META__ = { env: { VITE_OSRM_URL: 'http://osrm.internal:5000' } };
global.fetch.mockResolvedValue(osrmOk(8200, 900));
let reloaded;
jest.isolateModules(() => {
// eslint-disable-next-line global-require
reloaded = require('@/lib/distance').calculateDrivingDistance;
});
await reloaded(BLR, IND);
expect(global.fetch.mock.calls[0][0]).toContain('http://osrm.internal:5000');
});
it('should report no duration when OSRM omits one', async () => {
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ routes: [{ distance: 8200 }] }) });
await calculateDrivingDistance(BLR, IND);
expect(getLastRouteDurationMin()).toBeNull();
});
});
describe('Haversine fallback', () => {
it('should fall back when OSRM answers with an error status', async () => {
global.fetch.mockResolvedValue({ ok: false, status: 502, json: async () => ({}) });
const distance = await calculateDrivingDistance(BLR, IND);
expect(distance).toEqual(expect.any(Number));
expect(distance).toBeGreaterThan(0);
});
it('should fall back when the network call throws', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
global.fetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(calculateDrivingDistance(BLR, IND)).resolves.toEqual(expect.any(Number));
expect(warn).toHaveBeenCalled();
});
it('should fall back when OSRM returns no routes', async () => {
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ routes: [] }) });
await expect(calculateDrivingDistance(BLR, IND)).resolves.toEqual(expect.any(Number));
});
it('should apply the 1.3 road multiplier to the straight-line distance', async () => {
// One degree of latitude is 111.19 km great-circle; x1.3 = 144.5 -> 145.
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
const distance = await calculateDrivingDistance(
{ latitude: 0, longitude: 0 },
{ latitude: 1, longitude: 0 }
);
expect(distance).toBe(145);
});
it('should return zero for two identical points', async () => {
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
await expect(calculateDrivingDistance(BLR, { ...BLR })).resolves.toBe(0);
});
it('should report NO duration for a fallback estimate', async () => {
// A straight-line guess has no honest ETA. Leaving a stale routed value
// in place would attach a real-looking duration to a fabricated distance.
global.fetch.mockResolvedValueOnce(osrmOk(8200, 900));
await calculateDrivingDistance(BLR, IND);
expect(getLastRouteDurationMin()).toBe(15);
global.fetch.mockResolvedValueOnce({ ok: false, json: async () => ({}) });
await calculateDrivingDistance(BLR, IND);
expect(getLastRouteDurationMin()).toBeNull();
});
it('should be symmetric — the distance does not depend on direction', async () => {
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
const there = await calculateDrivingDistance(BLR, IND);
const back = await calculateDrivingDistance(IND, BLR);
expect(there).toBe(back);
});
});
});
describe('calculateTotalCharge', () => {
it('should charge only the base price below the minimum distance', () => {
expect(calculateTotalCharge(3, 30, 6, 8)).toBe(30);
});
it('should charge only the base price at exactly the minimum distance', () => {
// Boundary: minKm is inclusive of the base fare.
expect(calculateTotalCharge(8, 30, 6, 8)).toBe(30);
});
it('should add the per-km rate beyond the minimum', () => {
expect(calculateTotalCharge(12, 30, 6, 8)).toBe(54);
});
it('should add a partial kilometre proportionally', () => {
expect(calculateTotalCharge(8.5, 30, 6, 8)).toBeCloseTo(33, 6);
});
it('should charge the base price for a zero-distance leg', () => {
expect(calculateTotalCharge(0, 30, 6, 8)).toBe(30);
});
it('should charge the base price when there is no minimum', () => {
expect(calculateTotalCharge(0, 30, 6, 0)).toBe(30);
});
it('should scale linearly for a long leg', () => {
expect(calculateTotalCharge(108, 30, 6, 8)).toBe(630);
});
});

View File

@@ -0,0 +1,312 @@
import { renderHook, act } from '@testing-library/react';
import { currency, number, km, percentOf, orDash, matchesQuery, useDebouncedValue, exportRows } from '@/lib/doormileFormat';
/**
* `xlsx` is imported on demand inside exportRows, so it is mocked at the module
* boundary. The export path itself — column declaration to sheet rows to the
* written filename — is exactly what these tests verify, so none of that is
* mocked away.
*/
jest.mock('xlsx', () => ({
__esModule: true,
utils: {
json_to_sheet: jest.fn(() => ({ sheet: true })),
book_new: jest.fn(() => ({ book: true })),
book_append_sheet: jest.fn()
},
writeFile: jest.fn()
}));
import * as XLSX from 'xlsx';
/**
* Shared display formatting. The reason it lives in one module is that a number
* reading ₹1,240 on Orders and 1240 on Reports looks like two different figures
* for the same money — so these tests pin the exact rendering contract, and in
* particular that 0 is a real value while blank/NaN is a dash.
*/
describe('doormileFormat', () => {
describe('currency', () => {
it('should render an amount in rupees with Indian digit grouping', () => {
expect(currency(1240)).toMatch(/^₹\s?1,240\.00$/);
});
it('should group in lakhs, not thousands', () => {
// 12,34,567 — not 1,234,567. The console is an Indian ops tool.
expect(currency(1234567)).toMatch(/12,34,567\.00$/);
});
it('should render zero as a real amount, not a dash', () => {
// A ₹0 delivery fee is a fact; an em dash reads as "unknown".
expect(currency(0)).toMatch(/^₹\s?0\.00$/);
});
it('should accept a numeric string, as the API returns for money columns', () => {
expect(currency('1240')).toMatch(/1,240\.00$/);
});
it('should cap the fraction at two digits', () => {
expect(currency(10.999)).toMatch(/^₹\s?11$|^₹\s?11\.00$/);
expect(currency(10.994)).toMatch(/10\.99$/);
});
it('should render a negative amount rather than dropping the sign', () => {
expect(currency(-500)).toMatch(/500\.00$/);
expect(currency(-500)).toMatch(/-|−|\(/);
});
it.each([undefined, 'abc', NaN, Infinity, {}])(
'should fall back to an em dash for the unusable value %p',
(value) => {
expect(currency(value)).toBe('—');
}
);
it('should fall back to an em dash for a null amount', () => {
// A nullable money column means UNKNOWN, not zero. Rendering ₹0.00 asserts
// the delivery earned nothing, which is the exact confusion the module's
// own "0 is a real amount" comment exists to prevent.
expect(currency(null)).toBe('—');
});
it('should fall back to an em dash for an empty-string amount', () => {
expect(currency('')).toBe('—');
});
it('should honour a caller-supplied fallback', () => {
expect(currency('abc', 'n/a')).toBe('n/a');
});
});
describe('number', () => {
it('should render a count with Indian grouping', () => {
expect(number(1234567)).toBe('12,34,567');
});
it('should render zero as 0', () => {
expect(number(0)).toBe('0');
});
it.each([undefined, 'abc', NaN, Infinity])('should fall back to an em dash for %p', (value) => {
expect(number(value)).toBe('—');
});
it('should fall back to an em dash for a null count', () => {
// Same unknown-vs-zero distinction as `currency`.
expect(number(null)).toBe('—');
});
it('should fall back to an em dash for an empty-string count', () => {
expect(number('')).toBe('—');
});
});
describe('km', () => {
it('should render a distance to exactly one decimal place', () => {
expect(km(12)).toBe('12.0 km');
expect(km(12.34)).toBe('12.3 km');
expect(km(12.36)).toBe('12.4 km');
});
it('should render a zero distance rather than a dash', () => {
expect(km(0)).toBe('0.0 km');
});
it.each([undefined, 'abc', NaN, Infinity])('should fall back to an em dash for %p', (value) => {
expect(km(value)).toBe('—');
});
it('should fall back to an em dash for a null distance', () => {
// "0.0 km" for an unknown distance is worse than a dash: it reads as a
// measured zero-distance trip.
expect(km(null)).toBe('—');
});
});
describe('percentOf', () => {
it('should return a whole-number percentage', () => {
expect(percentOf(25, 100)).toBe(25);
expect(percentOf(1, 3)).toBe(33);
expect(percentOf(2, 3)).toBe(67);
});
it('should return 0 — not Infinity or NaN — when the total is zero', () => {
// This feeds progress bars and chip counts; NaN% renders as garbage.
expect(percentOf(5, 0)).toBe(0);
});
it.each([
[null, 100],
[10, null],
['abc', 100],
[10, 'abc'],
[undefined, undefined]
])('should return 0 for the non-numeric pair (%p, %p)', (value, total) => {
expect(percentOf(value, total)).toBe(0);
});
it('should allow a percentage above 100 rather than clamping silently', () => {
expect(percentOf(150, 100)).toBe(150);
});
});
describe('orDash', () => {
it.each([null, undefined, '', ' '])('should render the blank value %p as an em dash', (value) => {
expect(orDash(value)).toBe('—');
});
it('should trim surrounding whitespace off a real value', () => {
expect(orDash(' Bengaluru ')).toBe('Bengaluru');
});
it('should render 0 and false as text, since they are real values', () => {
expect(orDash(0)).toBe('0');
expect(orDash(false)).toBe('false');
});
});
describe('matchesQuery', () => {
const row = { customername: 'Anita Rao', city: 'Bengaluru', phone: '9876543210', notes: null };
it('should match a case-insensitive substring on any listed field', () => {
expect(matchesQuery(row, ['customername', 'city'], 'anita')).toBe(true);
expect(matchesQuery(row, ['customername', 'city'], 'BENGAL')).toBe(true);
});
it('should not match a field that was not listed', () => {
expect(matchesQuery(row, ['city'], 'anita')).toBe(false);
});
it('should support a derived field expressed as a function', () => {
expect(matchesQuery(row, [(r) => `${r.customername} ${r.city}`], 'rao beng')).toBe(true);
});
it.each(['', ' ', null, undefined])(
'should treat the blank query %p as "match everything"',
(query) => {
expect(matchesQuery(row, ['customername'], query)).toBe(true);
}
);
it('should trim the query before matching', () => {
expect(matchesQuery(row, ['city'], ' bengaluru ')).toBe(true);
});
it('should match against a numeric field coerced to text', () => {
expect(matchesQuery({ orderid: 4021 }, ['orderid'], '402')).toBe(true);
});
it('should skip null/undefined/empty fields instead of matching them', () => {
expect(matchesQuery(row, ['notes'], 'null')).toBe(false);
expect(matchesQuery(row, ['missing'], 'undefined')).toBe(false);
});
it('should return false for a row with no matching field rather than throwing', () => {
expect(matchesQuery(null, ['customername'], 'anita')).toBe(false);
expect(matchesQuery(row, [], 'anita')).toBe(false);
});
});
describe('useDebouncedValue', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('should return the initial value immediately', () => {
const { result } = renderHook(() => useDebouncedValue('a', 250));
expect(result.current).toBe('a');
});
it('should not settle until the delay has fully elapsed', () => {
const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 250), {
initialProps: { value: 'a' }
});
rerender({ value: 'ab' });
act(() => {
jest.advanceTimersByTime(249);
});
expect(result.current).toBe('a');
act(() => {
jest.advanceTimersByTime(1);
});
expect(result.current).toBe('ab');
});
it('should collapse a burst of keystrokes into a single settled value', () => {
const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 250), {
initialProps: { value: '' }
});
['b', 'be', 'ben', 'beng'].forEach((value) => {
rerender({ value });
act(() => {
jest.advanceTimersByTime(100);
});
});
expect(result.current).toBe('');
act(() => {
jest.advanceTimersByTime(250);
});
expect(result.current).toBe('beng');
});
it('should clear its pending timer on unmount', () => {
const clearSpy = jest.spyOn(global, 'clearTimeout');
const { unmount } = renderHook(() => useDebouncedValue('a', 250));
unmount();
expect(clearSpy).toHaveBeenCalled();
});
});
describe('exportRows', () => {
it('should build one sheet record per row using the column headers', async () => {
const rows = [
{ orderid: 1, customername: 'Anita', amount: 240 },
{ orderid: 2, customername: 'Vikram', amount: 90 }
];
const columns = [
{ key: 'orderid', header: 'Order ID' },
{ key: 'customername', header: 'Customer' }
];
await exportRows(rows, columns, 'orders-2026-08-27');
expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([
{ 'Order ID': 1, Customer: 'Anita' },
{ 'Order ID': 2, Customer: 'Vikram' }
]);
expect(XLSX.writeFile).toHaveBeenCalledWith({ book: true }, 'orders-2026-08-27.xlsx');
});
it('should prefer a column value() accessor over the raw key', async () => {
await exportRows([{ kms: 12.345 }], [{ key: 'kms', header: 'Distance', value: (r) => `${r.kms.toFixed(1)} km` }], 'x');
expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([{ Distance: '12.3 km' }]);
});
it('should write a blank cell — not the word null — for a missing value', async () => {
await exportRows([{ a: null, b: undefined }], [{ key: 'a', header: 'A' }, { key: 'b', header: 'B' }], 'x');
expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([{ A: '', B: '' }]);
});
it('should preserve a zero value rather than blanking it', async () => {
await exportRows([{ amount: 0 }], [{ key: 'amount', header: 'Amount' }], 'x');
expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([{ Amount: 0 }]);
});
it.each([[[]], [null], [undefined]])(
'should still produce a (empty) workbook when rows is %p',
async (rows) => {
await exportRows(rows, [{ key: 'a', header: 'A' }], 'empty');
expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([]);
expect(XLSX.writeFile).toHaveBeenCalledWith(expect.anything(), 'empty.xlsx');
}
);
it('should name the sheet consistently so downstream parsers can find it', async () => {
await exportRows([{ a: 1 }], [{ key: 'a', header: 'A' }], 'x');
expect(XLSX.utils.book_append_sheet).toHaveBeenCalledWith({ book: true }, { sheet: true }, 'Export');
});
});
});

View File

@@ -0,0 +1,133 @@
import dayjs from 'dayjs';
import { parseDoormileTimestamp, formatDoormileTimestamp, doormileDay } from '@/lib/doormileTimestamp';
/**
* Doormile timestamps are IST wall-clock in naive Postgres columns, and some
* responses carry a FALSE trailing `Z` (a Go+pgx marshalling footgun). Parsed
* naively, dayjs reads that Z as a real UTC instant and adds +5:30 to digits
* that were already correct.
*
* These tests protect the one property the whole app depends on: whatever zone
* marker a timestamp arrives with, the rendered and bucketed result is the raw
* wall-clock digits — asserted without depending on the machine's own timezone.
*/
describe('doormileTimestamp', () => {
describe('parseDoormileTimestamp', () => {
it('should ignore a false trailing Z rather than shifting the clock by +5:30', () => {
const parsed = parseDoormileTimestamp('2026-08-27T14:30:00Z');
expect(parsed.isValid()).toBe(true);
expect(parsed.hour()).toBe(14);
expect(parsed.minute()).toBe(30);
expect(parsed.date()).toBe(27);
});
it('should strip a +05:30 offset and keep the same wall-clock digits', () => {
const parsed = parseDoormileTimestamp('2026-08-27T14:30:00+05:30');
expect(parsed.hour()).toBe(14);
expect(parsed.minute()).toBe(30);
});
it('should strip a colon-less offset such as +0530', () => {
expect(parseDoormileTimestamp('2026-08-27T14:30:00+0530').hour()).toBe(14);
});
it('should strip a negative offset too', () => {
expect(parseDoormileTimestamp('2026-08-27T14:30:00-04:00').hour()).toBe(14);
});
it('should parse an already-naive timestamp unchanged', () => {
const parsed = parseDoormileTimestamp('2026-08-27T14:30:00');
expect(parsed.hour()).toBe(14);
expect(parsed.date()).toBe(27);
});
it('should produce the identical instant for the naive and the false-Z form', () => {
const naive = parseDoormileTimestamp('2026-08-27T23:45:00');
const falseZ = parseDoormileTimestamp('2026-08-27T23:45:00Z');
expect(falseZ.valueOf()).toBe(naive.valueOf());
});
it.each([null, undefined, '', 0, false])(
'should return an invalid dayjs (not throw) for the blank value %p',
(raw) => {
expect(parseDoormileTimestamp(raw).isValid()).toBe(false);
}
);
it('should return an invalid dayjs for unparseable text', () => {
expect(parseDoormileTimestamp('not-a-timestamp').isValid()).toBe(false);
});
it('should accept a bare date with no time component', () => {
const parsed = parseDoormileTimestamp('2026-08-27');
expect(parsed.isValid()).toBe(true);
expect(parsed.format('YYYY-MM-DD')).toBe('2026-08-27');
});
it('should accept a Date instance without throwing', () => {
expect(parseDoormileTimestamp(new Date('2026-08-27T10:00:00Z')).isValid()).toBe(true);
});
});
describe('formatDoormileTimestamp', () => {
it('should render the default operator-facing format', () => {
expect(formatDoormileTimestamp('2026-08-27T14:30:00Z')).toBe('27 Aug 2026, 02:30 PM');
});
it('should honour a caller-supplied format', () => {
expect(formatDoormileTimestamp('2026-08-27T14:30:00Z', 'YYYY-MM-DD HH:mm')).toBe('2026-08-27 14:30');
});
it('should return the em-dash fallback for an unusable timestamp', () => {
expect(formatDoormileTimestamp(null)).toBe('—');
expect(formatDoormileTimestamp('garbage')).toBe('—');
});
it('should return a caller-supplied fallback instead of the em dash', () => {
expect(formatDoormileTimestamp(undefined, 'DD MMM', 'Not set')).toBe('Not set');
});
});
describe('doormileDay', () => {
it('should return the calendar day the wall-clock reading falls on', () => {
expect(doormileDay('2026-08-27T14:30:00Z')).toBe('2026-08-27');
});
it('should keep a late-evening row on its own day rather than rolling it forward', () => {
expect(doormileDay('2026-08-27T23:45:00Z')).toBe('2026-08-27');
});
it('should keep an early-morning row on its own day', () => {
expect(doormileDay('2026-08-27T00:15:00Z')).toBe('2026-08-27');
});
it('should return undefined — not a string — for an unusable timestamp', () => {
// Callers compare this against a selected date; a formatted "Invalid Date"
// would silently never match.
expect(doormileDay(null)).toBeUndefined();
expect(doormileDay('garbage')).toBeUndefined();
});
it('should agree across every zone-marker spelling of the same reading', () => {
const forms = [
'2026-08-27T23:45:00',
'2026-08-27T23:45:00Z',
'2026-08-27T23:45:00+05:30',
'2026-08-27T23:45:00+0000'
];
const days = forms.map(doormileDay);
expect(new Set(days).size).toBe(1);
expect(days[0]).toBe('2026-08-27');
});
it('should differ from a bare dayjs() parse whenever the runner is not on UTC', () => {
// Guards the regression directly: bare dayjs() on a Z-suffixed IST reading
// lands on a different day in any non-UTC zone.
const raw = '2026-08-27T23:45:00Z';
expect(doormileDay(raw)).toBe('2026-08-27');
if (dayjs(raw).utcOffset() > 15) {
expect(dayjs(raw).format('YYYY-MM-DD')).toBe('2026-08-28');
}
});
});
});

View File

@@ -0,0 +1,133 @@
import {
ORDER_STATUS_GROUPS,
ORDER_STATUS_LABELS,
ORDER_STATUS_ORDER,
statusesInGroup,
groupForBookingStatus,
isInGroup,
} from '@/lib/orderStatusGroups';
/**
* The Orders page's tab taxonomy. What these tests protect is that every raw
* backend booking enum lands in exactly one operator-facing tab, that an
* unmapped enum stays visible rather than vanishing from the totals, and that
* this taxonomy stays deliberately DIFFERENT from the Deliveries one (the file
* header calls the split explicit product direction that was reverted once).
*/
describe('orderStatusGroups', () => {
describe('shape of the taxonomy', () => {
it('should expose a label for every group key', () => {
Object.keys(ORDER_STATUS_GROUPS).forEach((group) => {
expect(ORDER_STATUS_LABELS[group]).toEqual(expect.any(String));
expect(ORDER_STATUS_LABELS[group]).not.toBe('');
});
});
it('should order the tabs to match the lifecycle and cover every group', () => {
expect(ORDER_STATUS_ORDER).toEqual(['pending', 'assigned', 'active', 'delivered', 'cancelled']);
expect([...ORDER_STATUS_ORDER].sort()).toEqual(Object.keys(ORDER_STATUS_GROUPS).sort());
});
it('should never assign the same raw status to two groups', () => {
const seen = new Map();
Object.entries(ORDER_STATUS_GROUPS).forEach(([group, raws]) => {
raws.forEach((raw) => {
expect(seen.has(raw)).toBe(false);
seen.set(raw, group);
});
});
});
it('should keep every raw status lowercase so lookups cannot miss on case', () => {
Object.values(ORDER_STATUS_GROUPS)
.flat()
.forEach((raw) => expect(raw).toBe(raw.toLowerCase()));
});
});
describe('groupForBookingStatus', () => {
it.each([
['pending_pickup', 'pending'],
['unassigned', 'pending'],
['miler_assigned', 'assigned'],
['converted_to_consignment', 'assigned'],
['pickup_scheduled', 'assigned'],
['out_for_delivery', 'active'],
['delivered', 'delivered'],
['cancelled', 'cancelled'],
['rejected', 'cancelled'],
])('should map raw booking status %s to the %s tab', (raw, group) => {
expect(groupForBookingStatus(raw)).toBe(group);
});
it('should keep Miler_Assigned on the Assigned tab — assigning is an operator action here', () => {
// Deliberately NOT the Deliveries taxonomy, where miler_assigned is still
// 'pending' until the rider themselves accepts.
expect(groupForBookingStatus('Miler_Assigned')).toBe('assigned');
});
it('should be case-insensitive about the backend enum casing', () => {
expect(groupForBookingStatus('OUT_FOR_DELIVERY')).toBe('active');
expect(groupForBookingStatus('Pending_Pickup')).toBe('pending');
});
it('should return the lowercased raw value for an enum it has never seen', () => {
// An unmapped status must stay visible in a breakdown, not vanish.
expect(groupForBookingStatus('Inwarded_At_Hub')).toBe('inwarded_at_hub');
expect(groupForBookingStatus('brand_new_backend_state')).toBe('brand_new_backend_state');
});
it.each([
[null, 'pending'],
[undefined, 'pending'],
['', 'pending'],
])('should treat a blank status (%p) as pending rather than crashing', (raw, expected) => {
expect(groupForBookingStatus(raw)).toBe(expected);
});
it('should not throw on a non-string status', () => {
expect(() => groupForBookingStatus(42)).not.toThrow();
expect(groupForBookingStatus(42)).toBe('42');
});
});
describe('statusesInGroup', () => {
it('should return the raw enums backing a known group', () => {
expect(statusesInGroup('delivered')).toEqual(['delivered', 'completed', 'success']);
});
it('should return an empty array for an unknown group instead of undefined', () => {
// Callers spread/`.includes()` this directly — undefined would throw.
expect(statusesInGroup('nope')).toEqual([]);
expect(statusesInGroup(undefined)).toEqual([]);
});
});
describe('isInGroup', () => {
it('should confirm membership case-insensitively', () => {
expect(isInGroup('Out_For_Delivery', 'active')).toBe(true);
expect(isInGroup('delivered', 'active')).toBe(false);
});
it('should return false for an unknown group rather than throwing', () => {
expect(isInGroup('delivered', 'not_a_group')).toBe(false);
});
it('should treat a null status as not in any non-pending group', () => {
expect(isInGroup(null, 'delivered')).toBe(false);
// '' is an explicit member of the pending group.
expect(isInGroup(null, 'pending')).toBe(true);
});
});
describe('coverage of the tab set', () => {
it('should place every raw status into a group whose label is rendered', () => {
Object.values(ORDER_STATUS_GROUPS)
.flat()
.forEach((raw) => {
const group = groupForBookingStatus(raw);
expect(ORDER_STATUS_ORDER).toContain(group);
});
});
});
});

View File

@@ -0,0 +1,159 @@
import { renderHook, act } from '@testing-library/react';
import { DEFAULT_PREFERENCES, writePreferences, usePreferences, useUpdatePreferences } from '@/lib/preferences';
const STORAGE_KEY = 'doormilePreferences';
/**
* Operator display preferences.
*
* Two properties matter: unknown/corrupt storage must never take a page down,
* and a change must reach EVERY mounted consumer including the one that made it
* — `storage` alone fires only in other tabs, which left the table that
* triggered the change unchanged.
*/
describe('preferences', () => {
describe('defaults', () => {
it('should default to comfortable density and a 25-row page', () => {
expect(DEFAULT_PREFERENCES).toEqual({ compactDensity: false, defaultPageSize: 25 });
});
it('should read the defaults when nothing is stored', () => {
const { result } = renderHook(() => usePreferences());
expect(result.current).toEqual(DEFAULT_PREFERENCES);
});
it('should merge a partial stored record over the defaults', () => {
// A record written by an older build must not leave newer keys undefined.
localStorage.setItem(STORAGE_KEY, JSON.stringify({ compactDensity: true }));
const { result } = renderHook(() => usePreferences());
expect(result.current).toEqual({ compactDensity: true, defaultPageSize: 25 });
});
it('should fall back to the defaults for a corrupt stored record', () => {
localStorage.setItem(STORAGE_KEY, '{not json');
const { result } = renderHook(() => usePreferences());
expect(result.current).toEqual(DEFAULT_PREFERENCES);
});
it('should not hand out the shared defaults object itself', () => {
// A consumer mutating what it reads would silently rewrite the defaults
// for every other consumer.
const { result } = renderHook(() => usePreferences());
expect(result.current).not.toBe(DEFAULT_PREFERENCES);
});
});
describe('writePreferences', () => {
it('should persist a patch merged over what is already stored', () => {
writePreferences({ compactDensity: true });
writePreferences({ defaultPageSize: 100 });
expect(JSON.parse(localStorage.getItem(STORAGE_KEY))).toEqual({
compactDensity: true,
defaultPageSize: 100
});
});
it('should return the merged result to the caller', () => {
expect(writePreferences({ defaultPageSize: 50 })).toEqual({
compactDensity: false,
defaultPageSize: 50
});
});
it('should broadcast the change so the triggering component re-renders too', () => {
const listener = jest.fn();
window.addEventListener('doormile:preferences', listener);
writePreferences({ compactDensity: true });
window.removeEventListener('doormile:preferences', listener);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].detail).toEqual({ compactDensity: true, defaultPageSize: 25 });
});
it('should still broadcast when the write itself is refused', () => {
// Private mode / quota: the change applies for this session either way,
// so the UI must not be left showing the old value.
const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
const listener = jest.fn();
window.addEventListener('doormile:preferences', listener);
expect(() => writePreferences({ compactDensity: true })).not.toThrow();
expect(listener).toHaveBeenCalledTimes(1);
window.removeEventListener('doormile:preferences', listener);
setItem.mockRestore();
});
it('should accept an empty patch as a no-op that still returns the current values', () => {
expect(writePreferences({})).toEqual(DEFAULT_PREFERENCES);
});
});
describe('usePreferences', () => {
it('should re-render with the new value when a preference changes', () => {
const { result } = renderHook(() => usePreferences());
expect(result.current.compactDensity).toBe(false);
act(() => {
writePreferences({ compactDensity: true });
});
expect(result.current.compactDensity).toBe(true);
});
it('should pick up a change made in another tab', () => {
const { result } = renderHook(() => usePreferences());
act(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ defaultPageSize: 100 }));
window.dispatchEvent(new Event('storage'));
});
expect(result.current.defaultPageSize).toBe(100);
});
it('should keep every mounted consumer in agreement', () => {
const a = renderHook(() => usePreferences());
const b = renderHook(() => usePreferences());
act(() => {
writePreferences({ defaultPageSize: 50 });
});
expect(a.result.current).toEqual(b.result.current);
expect(a.result.current.defaultPageSize).toBe(50);
});
it('should stop listening once unmounted', () => {
const removeSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => usePreferences());
unmount();
expect(removeSpy).toHaveBeenCalledWith('doormile:preferences', expect.any(Function));
expect(removeSpy).toHaveBeenCalledWith('storage', expect.any(Function));
});
});
describe('useUpdatePreferences', () => {
it('should write and broadcast through the same path as writePreferences', () => {
const update = renderHook(() => useUpdatePreferences());
const read = renderHook(() => usePreferences());
act(() => {
update.result.current({ compactDensity: true });
});
expect(read.result.current.compactDensity).toBe(true);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)).compactDensity).toBe(true);
});
it('should keep a stable identity across renders so consumers do not re-run effects', () => {
const { result, rerender } = renderHook(() => useUpdatePreferences());
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});
});

View File

@@ -0,0 +1,327 @@
import {
BASE_REVENUE,
BASE_KM_LIMIT,
EXTRA_RATE_KM,
FIXED_COST_PER_SLOT,
VARIABLE_RATE_KM,
rowDay,
calcRiderMetrics,
groupRowsByRider
} from '@/lib/profitability';
/**
* The unit economics of a delivery day. Every figure the Profitability report
* shows comes from here, so the headline and the table underneath it cannot
* disagree.
*
* The behaviours worth protecting, in order of how much money they move:
* 1. Cancelled/skipped stops earn nothing AND cost nothing.
* 2. Slot count (which drives fixed cost) is derived from `orderdate`, never
* `assigntime` — the latter is re-stamped by any status change.
* 3. A bare date has no slot, because midnight would otherwise be assumed.
*/
const order = (over = {}) => ({ orderdate: '2026-08-27T07:00:00', riderkms: 5, orderstatus: 'delivered', ...over });
describe('profitability', () => {
describe('the published rate card', () => {
it('should charge a 30 rupee base for the first 8 km, then 6/km', () => {
expect(BASE_REVENUE).toBe(30);
expect(BASE_KM_LIMIT).toBe(8);
expect(EXTRA_RATE_KM).toBe(6);
});
it('should slice a 500 rupee salary across three slots', () => {
expect(FIXED_COST_PER_SLOT).toBeCloseTo(500 / 3, 10);
expect(VARIABLE_RATE_KM).toBe(2.5);
});
});
describe('rowDay', () => {
it('should return the creation day of a booking', () => {
expect(rowDay({ orderdate: '2026-08-27T07:00:00' })).toBe('2026-08-27');
});
it('should strip a false trailing Z so a late order stays on its own day', () => {
expect(rowDay({ orderdate: '2026-08-27T23:30:00Z' })).toBe('2026-08-27');
});
it.each([{}, { orderdate: null }, { orderdate: '' }, { orderdate: 'garbage' }])(
'should return null for the unusable row %p',
(row) => {
expect(rowDay(row)).toBeNull();
}
);
it('should not throw on a null row', () => {
expect(rowDay(null)).toBeNull();
});
});
describe('calcRiderMetrics — revenue', () => {
it('should charge only the base fare for a stop inside the base km limit', () => {
const metrics = calcRiderMetrics({ orders: [order({ riderkms: 5 })] }, '2026-08-27');
expect(metrics.revenue).toBe(30);
});
it('should charge only the base fare at exactly the base km limit', () => {
// Boundary: 8 km is inclusive of the base fare, 8.01 is not.
expect(calcRiderMetrics({ orders: [order({ riderkms: 8 })] }, '2026-08-27').revenue).toBe(30);
});
it('should add the per-km rate beyond the base limit', () => {
// 12 km => 30 + (12 - 8) * 6 = 54
expect(calcRiderMetrics({ orders: [order({ riderkms: 12 })] }, '2026-08-27').revenue).toBe(54);
});
it('should accept riderkms as a numeric string, as the API returns it', () => {
expect(calcRiderMetrics({ orders: [order({ riderkms: '12' })] }, '2026-08-27').revenue).toBe(54);
});
it.each([null, undefined, '', 'abc', NaN])(
'should treat the unusable distance %p as zero km and still charge the base fare',
(riderkms) => {
const metrics = calcRiderMetrics({ orders: [order({ riderkms })] }, '2026-08-27');
expect(metrics.revenue).toBe(30);
expect(metrics.kms).toBe(0);
}
);
it('should sum revenue across every billable stop', () => {
const metrics = calcRiderMetrics(
{ orders: [order({ riderkms: 5 }), order({ riderkms: 12 })] },
'2026-08-27'
);
expect(metrics.revenue).toBe(84);
expect(metrics.kms).toBe(17);
});
});
describe('calcRiderMetrics — exclusions', () => {
it.each(['cancelled', 'canceled', 'skipped', 'CANCELLED', 'Skipped'])(
'should exclude a %s stop from revenue, km and slot cost',
(orderstatus) => {
const metrics = calcRiderMetrics(
{ orders: [order({ orderstatus, riderkms: 20 })] },
'2026-08-27'
);
expect(metrics.revenue).toBe(0);
expect(metrics.kms).toBe(0);
expect(metrics.slotCount).toBe(0);
expect(metrics.totalCost).toBe(0);
}
);
it('should still list an excluded stop in `orders` for the breakdown table', () => {
// The table deliberately shows cancelled stops for context; only the money
// excludes them.
const metrics = calcRiderMetrics(
{ orders: [order({ orderstatus: 'cancelled' }), order()] },
'2026-08-27'
);
expect(metrics.orders).toHaveLength(2);
expect(metrics.billable).toHaveLength(1);
});
it('should read `status` when `orderstatus` is absent', () => {
const metrics = calcRiderMetrics(
{ orders: [{ orderdate: '2026-08-27T07:00:00', riderkms: 5, status: 'cancelled' }] },
'2026-08-27'
);
expect(metrics.revenue).toBe(0);
});
it('should treat a stop with no status at all as billable', () => {
const metrics = calcRiderMetrics(
{ orders: [{ orderdate: '2026-08-27T07:00:00', riderkms: 5 }] },
'2026-08-27'
);
expect(metrics.revenue).toBe(30);
});
});
describe('calcRiderMetrics — slots and fixed cost', () => {
it('should charge one slot when every stop falls in the same wave', () => {
const metrics = calcRiderMetrics(
{ orders: [order({ orderdate: '2026-08-27T07:00:00' }), order({ orderdate: '2026-08-27T08:30:00' })] },
'2026-08-27'
);
expect(metrics.slotCount).toBe(1);
expect(metrics.fixedCost).toBeCloseTo(FIXED_COST_PER_SLOT, 6);
});
it('should charge one slot per distinct wave worked', () => {
const metrics = calcRiderMetrics(
{
orders: [
order({ orderdate: '2026-08-27T07:00:00' }),
order({ orderdate: '2026-08-27T11:00:00' }),
order({ orderdate: '2026-08-27T18:00:00' })
]
},
'2026-08-27'
);
expect(metrics.slotCount).toBe(3);
expect(metrics.fixedCost).toBeCloseTo(500, 6);
});
it('should never charge more than three slots for a single day', () => {
const orders = Array.from({ length: 12 }, (_, i) =>
order({ orderdate: `2026-08-27T${String(i * 2).padStart(2, '0')}:00:00` })
);
expect(calcRiderMetrics({ orders }, '2026-08-27').slotCount).toBe(3);
});
it('should charge no slot for a bare date, which has no time component', () => {
// Otherwise every date-only row would silently bill a Morning slot.
const metrics = calcRiderMetrics({ orders: [order({ orderdate: '2026-08-27' })] }, '2026-08-27');
expect(metrics.slotCount).toBe(0);
expect(metrics.fixedCost).toBe(0);
expect(metrics.revenue).toBe(30);
});
it('should charge no slot for an unparseable orderdate', () => {
const metrics = calcRiderMetrics({ orders: [order({ orderdate: 'garbage' })] }, undefined);
expect(metrics.slotCount).toBe(0);
});
it('should ignore assigntime, which is re-stamped by any status change', () => {
// Reading assigntime made a rider's slot count — and therefore their
// fixed cost — drift through the day.
const metrics = calcRiderMetrics(
{ orders: [order({ orderdate: '2026-08-27T07:00:00', assigntime: '2026-08-27T19:00:00' })] },
'2026-08-27'
);
expect(metrics.slotCount).toBe(1);
expect(metrics.fixedCost).toBeCloseTo(FIXED_COST_PER_SLOT, 6);
});
it('should charge slots per day when no date filter is applied', () => {
const metrics = calcRiderMetrics({
orders: [order({ orderdate: '2026-08-26T07:00:00' }), order({ orderdate: '2026-08-27T07:00:00' })]
});
expect(metrics.slotCount).toBe(2);
});
});
describe('calcRiderMetrics — cost, net and margin', () => {
it('should compute the full P&L for a two-wave day', () => {
const metrics = calcRiderMetrics(
{
orders: [
order({ orderdate: '2026-08-27T07:00:00', riderkms: 5 }),
order({ orderdate: '2026-08-27T10:00:00', riderkms: 10 })
]
},
'2026-08-27'
);
expect(metrics.revenue).toBe(72); // 30 + (30 + 2*6)
expect(metrics.kms).toBe(15);
expect(metrics.slotCount).toBe(2);
expect(metrics.varCost).toBeCloseTo(37.5, 6); // 15 * 2.5
expect(metrics.fixedCost).toBeCloseTo((500 / 3) * 2, 6);
expect(metrics.totalCost).toBeCloseTo(37.5 + (500 / 3) * 2, 6);
expect(metrics.net).toBeCloseTo(72 - (37.5 + (500 / 3) * 2), 6);
expect(metrics.margin).toBeCloseTo((metrics.net / 72) * 100, 6);
});
it('should report a zero margin — not NaN or Infinity — when there is no revenue', () => {
const metrics = calcRiderMetrics({ orders: [] }, '2026-08-27');
expect(metrics.revenue).toBe(0);
expect(metrics.margin).toBe(0);
expect(Number.isFinite(metrics.margin)).toBe(true);
});
it('should report a negative net when cost exceeds revenue', () => {
const metrics = calcRiderMetrics({ orders: [order({ riderkms: 1 })] }, '2026-08-27');
expect(metrics.net).toBeLessThan(0);
expect(metrics.margin).toBeLessThan(0);
});
it('should keep net equal to revenue minus total cost', () => {
const metrics = calcRiderMetrics(
{ orders: [order({ riderkms: 30 }), order({ orderdate: '2026-08-27T17:00:00', riderkms: 4 })] },
'2026-08-27'
);
expect(metrics.net).toBeCloseTo(metrics.revenue - metrics.totalCost, 9);
expect(metrics.totalCost).toBeCloseTo(metrics.varCost + metrics.fixedCost, 9);
});
});
describe('calcRiderMetrics — date filtering and empty input', () => {
it('should count only the selected day when a date is given', () => {
const metrics = calcRiderMetrics(
{ orders: [order({ orderdate: '2026-08-26T07:00:00' }), order({ orderdate: '2026-08-27T07:00:00' })] },
'2026-08-27'
);
expect(metrics.orders).toHaveLength(1);
expect(metrics.revenue).toBe(30);
});
it('should count every day when no date is given', () => {
const metrics = calcRiderMetrics({
orders: [order({ orderdate: '2026-08-26T07:00:00' }), order({ orderdate: '2026-08-27T07:00:00' })]
});
expect(metrics.orders).toHaveLength(2);
});
it('should return an all-zero result for a rider with no orders array', () => {
const metrics = calcRiderMetrics({}, '2026-08-27');
expect(metrics).toMatchObject({ revenue: 0, kms: 0, slotCount: 0, totalCost: 0, net: 0, margin: 0 });
expect(metrics.orders).toEqual([]);
});
it('should exclude a stop whose day cannot be determined when a date is selected', () => {
const metrics = calcRiderMetrics({ orders: [order({ orderdate: 'garbage' })] }, '2026-08-27');
expect(metrics.orders).toHaveLength(0);
expect(metrics.revenue).toBe(0);
});
});
describe('groupRowsByRider', () => {
const row = (over = {}) => ({ userid: 7, ridername: 'Anita', kms: 5, ...over });
it('should group every row belonging to the same rider', () => {
const riders = groupRowsByRider([row(), row({ kms: 9 })]);
expect(riders).toHaveLength(1);
expect(riders[0]).toMatchObject({ id: '7', riderName: 'Anita' });
expect(riders[0].orders).toHaveLength(2);
});
it('should map the row `kms` onto `riderkms`, which the money math reads', () => {
// A booking carries no road distance, so the straight-line proxy feeds the
// rate card. If this mapping breaks, every rider silently earns base fare.
const [rider] = groupRowsByRider([row({ kms: 12 })]);
expect(rider.orders[0].riderkms).toBe(12);
expect(calcRiderMetrics(rider).revenue).toBe(54);
});
it('should treat numeric and string user ids as the same rider', () => {
expect(groupRowsByRider([row({ userid: 7 }), row({ userid: '7' })])).toHaveLength(1);
});
it('should drop rows with no userid, which cannot be attributed to anyone', () => {
expect(groupRowsByRider([row({ userid: null }), row({ userid: undefined }), row()])).toHaveLength(1);
});
it('should fall back to a numbered placeholder when the rider has no name', () => {
const [rider] = groupRowsByRider([row({ ridername: null })]);
expect(rider.riderName).toBe('Rider #7');
});
it('should keep the first-seen name for a rider whose later rows are unnamed', () => {
const [rider] = groupRowsByRider([row({ ridername: 'Anita' }), row({ ridername: null })]);
expect(rider.riderName).toBe('Anita');
});
it.each([[[]], [null], [undefined]])('should return an empty list for %p', (rows) => {
expect(groupRowsByRider(rows)).toEqual([]);
});
it('should not mutate the caller rows', () => {
const source = row();
groupRowsByRider([source]);
expect(source).not.toHaveProperty('riderkms');
});
});
});