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

3
.gitignore vendored
View File

@@ -32,3 +32,6 @@ dist-ssr
.env
.vite
base44/.app.jsonc
# Jest coverage output
coverage/

40
jest.config.cjs Normal file
View File

@@ -0,0 +1,40 @@
/**
* Jest for the Doormile console.
*
* Babel is configured INLINE rather than through a babel.config file at the
* repo root on purpose: @vitejs/plugin-react picks up root Babel config, so a
* babel.config.cjs added for tests would silently change how production
* bundles are compiled. Test tooling must not be able to move the build.
*/
const babelOptions = {
babelrc: false,
configFile: false,
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
plugins: [require.resolve('./tests/setup/babel-plugin-import-meta.cjs')],
};
module.exports = {
testEnvironment: 'jsdom',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.js', '**/*.test.jsx'],
setupFilesAfterEnv: ['<rootDir>/tests/setup/jest.setup.cjs'],
clearMocks: true,
restoreMocks: true,
transform: {
'^.+\.[jt]sx?$': ['babel-jest', babelOptions],
},
moduleNameMapper: {
'\.(css|less|scss|sass)$': '<rootDir>/tests/setup/styleMock.cjs',
'\.(png|jpe?g|gif|svg|webp|woff2?|ttf|eot)$': '<rootDir>/tests/setup/fileMock.cjs',
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/api/doormile/**/*.js',
'src/lib/**/*.js',
'src/utils/**/*.js',
'!src/**/*.md',
],
};

5246
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,10 @@
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"typecheck": "tsc -p ./jsconfig.json",
"preview": "vite preview"
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@astryxdesign/core": "^0.4.7",
@@ -51,17 +54,25 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@eslint/js": "^9.19.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.13.5",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"babel-jest": "^29.7.0",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-unused-imports": "^4.3.0",
"globals": "^15.14.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",

View File

@@ -157,7 +157,7 @@ export const deriveDeliveryStatus = (bookingOrStatus, consignmentMapOrReachedAt)
return isReached ? 'arrived' : 'accepted';
}
return BOOKING_STATUS_TO_DELIVERY_STATUS[rawBookingStatus] || rawBookingStatus;
return BOOKING_STATUS_TO_DELIVERY_STATUS[rawBookingStatus] || (rawBookingStatus ? rawBookingStatus : 'pending');
};
export const mapBookingStatusToDeliveryStatus = (statusOrRow, maybeReachedAt) => {

View File

@@ -18,18 +18,21 @@ const DECIMAL = new Intl.NumberFormat('en-IN');
/** Money, or a dash. `0` is a real amount and renders as ₹0.00. */
export const currency = (value, fallback = '—') => {
if (value === null || value === undefined || value === '') return fallback;
const n = Number(value);
return Number.isFinite(n) ? INR.format(n) : fallback;
};
/** A count with thousands separators, or a dash. */
export const number = (value, fallback = '—') => {
if (value === null || value === undefined || value === '') return fallback;
const n = Number(value);
return Number.isFinite(n) ? DECIMAL.format(n) : fallback;
};
/** A distance in km to one decimal, or a dash. */
export const km = (value, fallback = '—') => {
if (value === null || value === undefined || value === '') return fallback;
const n = Number(value);
return Number.isFinite(n) ? `${n.toFixed(1)} km` : fallback;
};

322
tests/api/client.test.js Normal file
View File

@@ -0,0 +1,322 @@
/**
* The Doormile admin API client: bearer-token attachment, 401 session ending,
* and the error-normalisation contract every write path depends on.
*
* axios is mocked at the module boundary so the two interceptors can be invoked
* directly — they are the unit under test, so nothing about their logic is
* stubbed, only the transport underneath them.
*/
jest.mock('axios', () => {
const handlers = { request: null, responseOk: null, responseErr: null };
const instance = {
interceptors: {
request: { use: (fn) => { handlers.request = fn; } },
response: {
use: (ok, err) => {
handlers.responseOk = ok;
handlers.responseErr = err;
}
}
}
};
const mockAxios = { create: jest.fn(() => instance), __handlers: handlers, __instance: instance };
return { __esModule: true, default: mockAxios };
});
import axios from 'axios';
import doormileAxios, {
DOORMILE_TOKEN_KEY,
DOORMILE_USER_KEY,
DOORMILE_SESSION_KEYS,
DOORMILE_ASSISTANT_KEYS,
DOORMILE_API_URL,
DOORMILE_BASE_URL,
clearStoredSession,
readStoredUser,
readStoredToken,
errorMessage
} from '@/api/doormile/client';
const handlers = axios.__handlers;
/** Replaces window.location with a spyable stand-in (jsdom forbids assignment). */
const stubLocation = (href) => {
const replace = jest.fn();
Object.defineProperty(window, 'location', {
writable: true,
configurable: true,
value: { href, replace }
});
return replace;
};
const rejection = (fn) => fn().then(
() => { throw new Error('expected the interceptor to reject'); },
(err) => err
);
describe('doormile API client', () => {
describe('base URL', () => {
it('should point at the real production API by default', () => {
expect(DOORMILE_API_URL).toBe('https://api.doormile.com/api/v1');
expect(DOORMILE_BASE_URL).toBe(DOORMILE_API_URL);
});
it('should create the axios instance against the resolved base URL', () => {
jest.isolateModules(() => {
globalThis.__VITE_IMPORT_META__ = { env: { DEV: false } };
// eslint-disable-next-line global-require
const reloaded = require('@/api/doormile/client');
expect(axios.create).toHaveBeenCalledWith({ baseURL: reloaded.DOORMILE_BASE_URL });
});
});
it('should let VITE_DOORMILE_URL redirect the console at a staging backend', () => {
jest.isolateModules(() => {
globalThis.__VITE_IMPORT_META__ = { env: { DEV: false, VITE_DOORMILE_URL: 'http://localhost:9000/api/v1' } };
// eslint-disable-next-line global-require
const reloaded = require('@/api/doormile/client');
expect(reloaded.DOORMILE_BASE_URL).toBe('http://localhost:9000/api/v1');
});
});
it('should warn loudly in development when the backend is overridden', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.isolateModules(() => {
globalThis.__VITE_IMPORT_META__ = { env: { DEV: true, VITE_DOORMILE_URL: 'http://localhost:9000/api/v1' } };
// eslint-disable-next-line global-require
require('@/api/doormile/client');
});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('http://localhost:9000/api/v1'));
expect(warn).toHaveBeenCalledWith(expect.stringContaining(DOORMILE_API_URL));
});
it('should stay silent in development when no override is set', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.isolateModules(() => {
globalThis.__VITE_IMPORT_META__ = { env: { DEV: true } };
// eslint-disable-next-line global-require
require('@/api/doormile/client');
});
expect(warn).not.toHaveBeenCalled();
});
});
describe('request interceptor', () => {
it('should attach the stored token as a Bearer credential', () => {
localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc');
const config = handlers.request({ headers: {} });
expect(config.headers.Authorization).toBe('Bearer jwt-abc');
});
it('should leave the request unauthenticated when no token is stored', () => {
// Login itself is issued through this instance; forcing a header would
// send `Bearer null` on the one call that must not carry one.
const config = handlers.request({ headers: {} });
expect(config.headers.Authorization).toBeUndefined();
});
it('should preserve headers the caller already set', () => {
localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc');
const config = handlers.request({ headers: { 'Content-Type': 'multipart/form-data' } });
expect(config.headers['Content-Type']).toBe('multipart/form-data');
expect(config.headers.Authorization).toBe('Bearer jwt-abc');
});
it('should return the same config object the caller passed', () => {
const config = { headers: {}, url: '/admin/bookings' };
expect(handlers.request(config)).toBe(config);
});
});
describe('response interceptor — success', () => {
it('should pass a successful response through untouched', () => {
const response = { status: 200, data: { success: true } };
expect(handlers.responseOk(response)).toBe(response);
});
});
describe('response interceptor — 401 handling', () => {
it('should end the session and send the operator back to login on a 401', () => {
const replace = stubLocation('http://console.local/deliveries');
localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc');
localStorage.setItem(DOORMILE_USER_KEY, '{"id":1}');
return rejection(() => handlers.responseErr({ response: { status: 401, data: { message: 'expired' } } })).then(() => {
expect(localStorage.getItem(DOORMILE_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(DOORMILE_USER_KEY)).toBeNull();
expect(replace).toHaveBeenCalledWith('/login');
});
});
it('should NOT redirect when the 401 came from the login page itself', async () => {
// A refused password must show the server's message in place, not bounce
// the operator through a page reload that loses what they typed.
const replace = stubLocation('http://console.local/login');
localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc');
await rejection(() => handlers.responseErr({ response: { status: 401, data: { message: 'bad password' } } }));
expect(replace).not.toHaveBeenCalled();
expect(localStorage.getItem(DOORMILE_TOKEN_KEY)).toBe('jwt-abc');
});
it.each([400, 403, 404, 409, 500])(
'should leave the session alone on a %d, which is not an auth failure',
async (status) => {
const replace = stubLocation('http://console.local/deliveries');
localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc');
await rejection(() => handlers.responseErr({ response: { status, data: { message: 'nope' } } }));
expect(replace).not.toHaveBeenCalled();
expect(localStorage.getItem(DOORMILE_TOKEN_KEY)).toBe('jwt-abc');
}
);
});
describe('response interceptor — error normalisation', () => {
beforeEach(() => stubLocation('http://console.local/deliveries'));
it("should reject with the server's own body so callers can read err.message", async () => {
const err = await rejection(() =>
handlers.responseErr({ response: { status: 400, data: { success: false, message: 'status is required' } } })
);
expect(err.message).toBe('status is required');
expect(err.success).toBe(false);
});
it('should keep the HTTP status reachable as httpStatus', async () => {
// Without this a 404, a 500 and a validation failure collapse into one
// generic failure — exactly the distinction the write paths need.
const err = await rejection(() =>
handlers.responseErr({ response: { status: 404, data: { message: 'not found' } } })
);
expect(err.httpStatus).toBe(404);
});
it('should make httpStatus non-enumerable so it never leaks into a spread', async () => {
const err = await rejection(() =>
handlers.responseErr({ response: { status: 409, data: { message: 'conflict' } } })
);
expect(Object.keys(err)).not.toContain('httpStatus');
expect(JSON.parse(JSON.stringify(err))).not.toHaveProperty('httpStatus');
expect({ ...err }.httpStatus).toBeUndefined();
expect(err.httpStatus).toBe(409);
});
it('should wrap a plain-text error body as { message }', async () => {
const err = await rejection(() =>
handlers.responseErr({ response: { status: 500, data: 'Internal Server Error' } })
);
expect(err.message).toBe('Internal Server Error');
expect(err.httpStatus).toBe(500);
});
it.each([
['a network failure with no response', { message: 'Network Error' }],
['a response carrying a null body', { response: { status: 502, data: null } }],
['a response carrying no body at all', { response: { status: 502 } }]
])('should reject with the original error for %s', async (_label, raw) => {
const err = await rejection(() => handlers.responseErr(raw));
expect(err).toBe(raw);
});
});
describe('clearStoredSession', () => {
it('should remove the credential, the identity and the assistant transcript', () => {
localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc');
localStorage.setItem(DOORMILE_USER_KEY, '{"id":1}');
DOORMILE_SESSION_KEYS.forEach((key) => localStorage.setItem(key, 'x'));
DOORMILE_ASSISTANT_KEYS.forEach((key) => localStorage.setItem(key, 'x'));
clearStoredSession();
[DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY, ...DOORMILE_SESSION_KEYS, ...DOORMILE_ASSISTANT_KEYS].forEach((key) => {
expect(localStorage.getItem(key)).toBeNull();
});
});
it('should clear tenantid, which decides staff-vs-tenant scope', () => {
// A stale tenantid is readable by the next operator before they sign in.
expect(DOORMILE_SESSION_KEYS).toContain('tenantid');
localStorage.setItem('tenantid', '42');
clearStoredSession();
expect(localStorage.getItem('tenantid')).toBeNull();
});
it('should clear the assistant transcript, which contains customer data', () => {
expect(DOORMILE_ASSISTANT_KEYS).toEqual(
expect.arrayContaining(['doormileBotHistory', 'doormileBotConversations'])
);
});
it('should leave non-session preferences in place', () => {
// The panel width is a preference, not session data — a shared warehouse
// terminal should keep its layout across sign-ins.
localStorage.setItem('doormileBotPanelWidth', '420');
localStorage.setItem('doormilePreferences', '{"compactDensity":true}');
clearStoredSession();
expect(localStorage.getItem('doormileBotPanelWidth')).toBe('420');
expect(localStorage.getItem('doormilePreferences')).toBe('{"compactDensity":true}');
});
it('should be safe to call when nothing is stored', () => {
expect(() => clearStoredSession()).not.toThrow();
});
});
describe('readStoredUser / readStoredToken', () => {
it('should return the parsed admin record', () => {
localStorage.setItem(DOORMILE_USER_KEY, JSON.stringify({ userid: 7, tenantid: '0' }));
expect(readStoredUser()).toEqual({ userid: 7, tenantid: '0' });
});
it('should return null when signed out', () => {
expect(readStoredUser()).toBeNull();
expect(readStoredToken()).toBeNull();
});
it('should return null — not throw — for a corrupt stored record', () => {
// A half-written record must not take the whole console down on boot.
localStorage.setItem(DOORMILE_USER_KEY, '{not json');
expect(readStoredUser()).toBeNull();
});
it('should return the raw token string', () => {
localStorage.setItem(DOORMILE_TOKEN_KEY, 'jwt-abc');
expect(readStoredToken()).toBe('jwt-abc');
});
});
describe('errorMessage', () => {
it("should prefer the normalised body's own message", () => {
expect(errorMessage({ message: 'status is required' })).toBe('status is required');
});
it('should fall back to a raw axios error body', () => {
expect(errorMessage({ response: { data: { message: 'server said no' } } })).toBe('server said no');
});
it('should prefer err.message over the nested response body', () => {
expect(errorMessage({ message: 'outer', response: { data: { message: 'inner' } } })).toBe('outer');
});
it.each([null, undefined, {}, { message: '' }])(
'should fall back to the default text for %p',
(err) => {
expect(errorMessage(err)).toBe('Something went wrong');
}
);
it('should honour a caller-supplied fallback', () => {
expect(errorMessage(null, 'Could not load deliveries')).toBe('Could not load deliveries');
});
});
describe('module surface', () => {
it('should export the configured axios instance as the default', () => {
expect(doormileAxios).toBe(axios.__instance);
});
});
});

View File

@@ -0,0 +1,556 @@
jest.mock('@/api/doormile/endpoints', () => ({
__esModule: true,
getBookings: jest.fn(),
getBooking: jest.fn(),
getConsignments: jest.fn(),
getAdminCustomers: jest.fn(),
getAdminTenants: jest.fn(),
getMilers: jest.fn(),
getMiler: jest.fn(),
getMilerSummary: jest.fn(),
getMilerLogs: jest.fn(),
notifyMiler: jest.fn(),
getTenantLocations: jest.fn(),
getHubs: jest.fn(),
assignMilerToBooking: jest.fn(),
cancelBooking: jest.fn(),
updateConsignmentStatus: jest.fn(),
getAdminPricing: jest.fn(),
getReports: jest.fn(),
getLocationsSummary: jest.fn(),
getProfile: jest.fn()
}));
jest.mock('@/api/doormile/notify', () => ({
__esModule: true,
OpenToast: jest.fn(),
messageOf: jest.fn((err, fallback) => err?.message || fallback)
}));
import * as endpoints from '@/api/doormile/endpoints';
import { OpenToast } from '@/api/doormile/notify';
import {
fetchDeliveries,
fetchCountAPI,
cancelDeliveryAPI,
changeRiderAPI,
updateDeliveryAPI,
getorderdetails
} from '@/api/doormile/queries';
/**
* A booking as GET /admin/bookings returns it. Defaults make the row
* "dispatched" (it carries an assigned rider), because fetchDeliveries only
* surfaces dispatched rows.
*/
const booking = (over = {}) => ({
bookingid: 4021,
bookingno: 'DM-4021',
status: 'Miler_Assigned',
assignedmileruserid: 91,
tenantid: 4,
createdat: '2026-08-27T10:00:00',
updatedat: '2026-08-27T10:05:00',
pickupaddress: '12 MG Road, Bengaluru',
deliveryaddress: '44 Indiranagar, Bengaluru',
...over
});
/** The query key every caller of fetchDeliveries builds. */
const key = ({ status = 'all', startdate, enddate, rowsPerPage = 100 } = {}) => [
'deliveries',
1,
null,
status,
startdate,
enddate,
rowsPerPage
];
const withNoJoins = () => {
endpoints.getAdminCustomers.mockResolvedValue([]);
endpoints.getMilers.mockResolvedValue([]);
endpoints.getAdminTenants.mockResolvedValue([]);
endpoints.getConsignments.mockResolvedValue([]);
};
describe('deliveries data layer', () => {
beforeEach(withNoJoins);
describe('fetchDeliveries — dispatched filtering', () => {
it('should surface a booking that already has a rider assigned', async () => {
endpoints.getBookings.mockResolvedValue([booking()]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ bookingid: 4021, deliveryid: 4021, orderheaderid: 4021 });
});
it.each(['pending_pickup', 'created', 'new', 'booked', 'order_placed', 'unassigned'])(
'should exclude an undispatched %s booking with no rider and no consignment',
async (status) => {
endpoints.getBookings.mockResolvedValue([
booking({ status, assignedmileruserid: null, consignmentid: null })
]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows).toEqual([]);
}
);
it('should surface an undispatched-looking booking that nonetheless has a consignment', async () => {
endpoints.getBookings.mockResolvedValue([
booking({ status: 'pending_pickup', assignedmileruserid: null, consignmentid: 40 })
]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows).toHaveLength(1);
expect(rows[0].consignmentid).toBe(40);
});
it('should surface a booking whose status has moved past the pre-dispatch set', async () => {
endpoints.getBookings.mockResolvedValue([
booking({ status: 'Out_for_Delivery', assignedmileruserid: null, consignmentid: null })
]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows).toHaveLength(1);
});
});
describe('fetchDeliveries — row mapping', () => {
it('should resolve the rider name from the joined miler list', async () => {
endpoints.getBookings.mockResolvedValue([booking()]);
endpoints.getMilers.mockResolvedValue([
{ userid: 91, milerprofileid: 11, firstname: 'Anita', lastname: 'Rao', phone: '9876543210' }
]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0]).toMatchObject({
ridername: 'Anita Rao',
userid: 91,
milerprofileid: 11,
ridercontact: '9876543210'
});
});
it('should fall back to a numbered rider label when the miler join fails', async () => {
// A failed join must degrade a display field, not blank the page.
endpoints.getBookings.mockResolvedValue([booking()]);
endpoints.getMilers.mockRejectedValue(new Error('milers 500'));
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows).toHaveLength(1);
expect(rows[0].ridername).toBe('Rider #91');
});
it('should resolve the delivery customer from the joined customer list', async () => {
endpoints.getBookings.mockResolvedValue([booking({ appcustomerid: 7 })]);
endpoints.getAdminCustomers.mockResolvedValue([
{ appcustomerid: 7, firstname: 'Vikram', phone: '9000000000' }
]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0]).toMatchObject({ deliverycustomer: 'Vikram', deliverycontactno: '9000000000', deliverytype: 'B' });
});
it('should mark a row with no matched customer as a C-type delivery', async () => {
endpoints.getBookings.mockResolvedValue([booking()]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0].deliverytype).toBe('C');
});
it('should bucket on the booking creation time, exposed as orderdate', async () => {
// Batch bucketing and date filtering both read orderdate; if this stops
// being createdat, rows are counted for a day but belong to no batch.
endpoints.getBookings.mockResolvedValue([booking({ createdat: '2026-08-27T07:00:00' })]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0].orderdate).toBe('2026-08-27T07:00:00');
});
it('should approximate distance as a straight line between the two coordinates', async () => {
endpoints.getBookings.mockResolvedValue([
booking({
pickuplatitude: 12.9716,
pickuplongitude: 77.5946,
deliverylatitude: 12.9352,
deliverylongitude: 77.6245
})
]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0].kms).toBeGreaterThan(4);
expect(rows[0].kms).toBeLessThan(7);
});
it('should leave distance undefined — not zero — when a coordinate is missing', async () => {
// A fabricated 0 km reads as a measured distance and feeds the rate card.
endpoints.getBookings.mockResolvedValue([booking({ pickuplatitude: null })]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0].kms).toBeUndefined();
});
it('should leave route-plan fields undefined rather than fabricating them', async () => {
endpoints.getBookings.mockResolvedValue([booking()]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0].transitminutes).toBeUndefined();
expect(rows[0].cumulativekms).toBeUndefined();
expect(rows[0].step).toBeUndefined();
});
it('should count parcels as the row quantity, defaulting to zero', async () => {
endpoints.getBookings.mockResolvedValue([booking({ parcels: [{}, {}] }), booking({ bookingid: 4022 })]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0].quantity).toBe(2);
expect(rows[1].quantity).toBe(0);
});
it('should carry the service charge through to every amount field the table reads', async () => {
endpoints.getBookings.mockResolvedValue([
booking({ serviceoptions: [{ estimatedprice: 240, estimateddeliveryat: '2026-08-27T12:00:00' }] })
]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0]).toMatchObject({ deliverycharges: 240, deliveryamt: 240, deliveryamount: 240 });
expect(rows[0].expecteddeliverytime).toBe('2026-08-27T12:00:00');
});
it('should let the consignment status drive the row status', async () => {
endpoints.getBookings.mockResolvedValue([
booking({ status: 'Converted_To_Consignment', consignmentid: 40 })
]);
endpoints.getConsignments.mockResolvedValue([{ consignmentid: 40, status: 'Out_for_Delivery' }]);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0].orderstatus).toBe('active');
expect(rows[0].statusfromconsignment).toBe(true);
});
it('should fall back to the booking status when the consignment join fails', async () => {
endpoints.getBookings.mockResolvedValue([
booking({ status: 'Converted_To_Consignment', consignmentid: 40 })
]);
endpoints.getConsignments.mockRejectedValue(new Error('consignments 500'));
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows[0].orderstatus).toBe('picked');
});
});
describe('fetchDeliveries — date range', () => {
const threeDays = [
booking({ bookingid: 1, createdat: '2026-08-25T10:00:00' }),
booking({ bookingid: 2, createdat: '2026-08-26T10:00:00' }),
booking({ bookingid: 3, createdat: '2026-08-27T10:00:00' })
];
it('should return every row when no range is given', async () => {
endpoints.getBookings.mockResolvedValue(threeDays);
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows).toHaveLength(3);
});
it('should apply the requested range instead of ignoring it', async () => {
// The regression: startdate/enddate were accepted and then dropped, so
// today, yesterday and last week all produced a byte-identical row set.
endpoints.getBookings.mockResolvedValue(threeDays);
const { rows } = await fetchDeliveries({
pageParam: 1,
queryKey: key({ startdate: '2026-08-26', enddate: '2026-08-26' })
});
expect(rows.map((r) => r.bookingid)).toEqual([2]);
});
it('should include both range boundaries', async () => {
endpoints.getBookings.mockResolvedValue(threeDays);
const { rows } = await fetchDeliveries({
pageParam: 1,
queryKey: key({ startdate: '2026-08-25', enddate: '2026-08-27' })
});
expect(rows).toHaveLength(3);
});
it('should treat a missing bound as unbounded on that side', async () => {
endpoints.getBookings.mockResolvedValue(threeDays);
const from = await fetchDeliveries({ pageParam: 1, queryKey: key({ startdate: '2026-08-26' }) });
expect(from.rows.map((r) => r.bookingid)).toEqual([2, 3]);
const until = await fetchDeliveries({ pageParam: 1, queryKey: key({ enddate: '2026-08-26' }) });
expect(until.rows.map((r) => r.bookingid)).toEqual([1, 2]);
});
it('should keep a late-evening row on its own day despite a false trailing Z', async () => {
// Unstripped, 23:45Z shifts +5:30 into the next day and drops out of the
// selected date entirely.
endpoints.getBookings.mockResolvedValue([booking({ createdat: '2026-08-27T23:45:00Z' })]);
const { rows } = await fetchDeliveries({
pageParam: 1,
queryKey: key({ startdate: '2026-08-27', enddate: '2026-08-27' })
});
expect(rows).toHaveLength(1);
});
it('should drop a row with an unparseable creation date when a range is set', async () => {
endpoints.getBookings.mockResolvedValue([booking({ createdat: 'garbage' })]);
const { rows } = await fetchDeliveries({
pageParam: 1,
queryKey: key({ startdate: '2026-08-27', enddate: '2026-08-27' })
});
expect(rows).toEqual([]);
});
it('should return an empty page for a range with no rows', async () => {
endpoints.getBookings.mockResolvedValue(threeDays);
const { rows } = await fetchDeliveries({
pageParam: 1,
queryKey: key({ startdate: '2026-01-01', enddate: '2026-01-02' })
});
expect(rows).toEqual([]);
});
});
describe('fetchDeliveries — pagination and failure', () => {
it('should advertise a next page when the server returned a full page', async () => {
endpoints.getBookings.mockResolvedValue(Array.from({ length: 25 }, (_, i) => booking({ bookingid: i + 1 })));
const result = await fetchDeliveries({ pageParam: 1, queryKey: key({ rowsPerPage: 25 }) });
expect(result.nextPage).toBe(2);
});
it('should stop paginating on a partial page', async () => {
endpoints.getBookings.mockResolvedValue(Array.from({ length: 9 }, (_, i) => booking({ bookingid: i + 1 })));
const result = await fetchDeliveries({ pageParam: 1, queryKey: key({ rowsPerPage: 25 }) });
expect(result.nextPage).toBeUndefined();
});
it('should request the page the caller asked for', async () => {
endpoints.getBookings.mockResolvedValue([]);
await fetchDeliveries({ pageParam: 3, queryKey: key({ rowsPerPage: 50 }) });
expect(endpoints.getBookings).toHaveBeenCalledWith(3, 50);
});
it('should default to the first page when pageParam is omitted', async () => {
endpoints.getBookings.mockResolvedValue([]);
await fetchDeliveries({ queryKey: key({ rowsPerPage: 50 }) });
expect(endpoints.getBookings).toHaveBeenCalledWith(1, 50);
});
it('should toast and return an empty page when the bookings call fails', async () => {
// The one join it cannot recover from — previously it rejected uncaught,
// with no toast anywhere across the four pages that call this.
endpoints.getBookings.mockRejectedValue({ message: 'bookings 503' });
const result = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(result).toEqual({ rows: [], nextPage: undefined });
expect(OpenToast).toHaveBeenCalledWith('bookings 503', 'error', 2000);
});
it('should still render rows when every optional join fails', async () => {
endpoints.getBookings.mockResolvedValue([booking()]);
endpoints.getAdminCustomers.mockRejectedValue(new Error('x'));
endpoints.getMilers.mockRejectedValue(new Error('x'));
endpoints.getAdminTenants.mockRejectedValue(new Error('x'));
endpoints.getConsignments.mockRejectedValue(new Error('x'));
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: key() });
expect(rows).toHaveLength(1);
});
it('should return an empty page for an account with no bookings', async () => {
endpoints.getBookings.mockResolvedValue([]);
await expect(fetchDeliveries({ pageParam: 1, queryKey: key() })).resolves.toEqual({
rows: [],
nextPage: undefined
});
});
});
describe('fetchCountAPI', () => {
it('should count dispatched rows into the page chip buckets', async () => {
endpoints.getBookings.mockResolvedValue([
booking({ bookingid: 1, status: 'Miler_Assigned' }),
booking({ bookingid: 2, status: 'Pickup_Scheduled' }),
booking({ bookingid: 3, status: 'Converted_To_Consignment' }),
booking({ bookingid: 4, status: 'Out_for_Delivery' }),
booking({ bookingid: 5, status: 'Delivered' }),
booking({ bookingid: 6, status: 'Cancelled' })
]);
await expect(fetchCountAPI()).resolves.toEqual({
total: 6,
uncoveredLength: 1,
assignedLength: 1,
arrivedLength: 0,
pickedLength: 1,
activeLength: 1,
coveredLength: 1,
cancelLength: 1,
skippedLength: 0
});
});
it('should count only dispatched bookings, matching what the table shows', async () => {
endpoints.getBookings.mockResolvedValue([
booking({ bookingid: 1, status: 'pending_pickup', assignedmileruserid: null }),
booking({ bookingid: 2, status: 'Delivered' })
]);
const counts = await fetchCountAPI();
expect(counts.total).toBe(1);
expect(counts.coveredLength).toBe(1);
});
it('should report zeroes — not undefined — for an empty account', async () => {
endpoints.getBookings.mockResolvedValue([]);
const counts = await fetchCountAPI();
Object.values(counts).forEach((value) => expect(value).toBe(0));
});
it('should report zeroes rather than throwing when the source call fails', async () => {
endpoints.getBookings.mockRejectedValue(new Error('boom'));
const counts = await fetchCountAPI();
expect(counts.total).toBe(0);
});
});
describe('cancelDeliveryAPI', () => {
it('should cancel through the booking-level cancel action', async () => {
endpoints.cancelBooking.mockResolvedValue({ success: true });
await cancelDeliveryAPI({ orderheaderid: 4021 }, 'customer unreachable');
expect(endpoints.cancelBooking).toHaveBeenCalledWith(4021, { reason: 'customer unreachable' });
});
it('should fall back to deliveryid when the row carries no orderheaderid', async () => {
endpoints.cancelBooking.mockResolvedValue({ success: true });
await cancelDeliveryAPI({ deliveryid: 4022 }, 'duplicate');
expect(endpoints.cancelBooking).toHaveBeenCalledWith(4022, { reason: 'duplicate' });
});
it('should send an undefined reason rather than inventing one', async () => {
endpoints.cancelBooking.mockResolvedValue({ success: true });
await cancelDeliveryAPI({ orderheaderid: 4021 });
expect(endpoints.cancelBooking).toHaveBeenCalledWith(4021, { reason: undefined });
});
it('should surface a rejected cancel to the caller', async () => {
endpoints.cancelBooking.mockRejectedValue({ message: 'already cancelled', httpStatus: 409 });
await expect(cancelDeliveryAPI({ orderheaderid: 4021 }, 'x')).rejects.toMatchObject({ httpStatus: 409 });
});
});
describe('changeRiderAPI', () => {
it("should send the miler's USERID under the mileruserid key", async () => {
// assign-miler is the one admin route keyed on userid rather than
// milerprofileid. The previous guess was wrong on both key and value.
endpoints.assignMilerToBooking.mockResolvedValue({ success: true });
await changeRiderAPI({ userid: 91, milerprofileid: 11 }, { orderheaderid: 4021 });
expect(endpoints.assignMilerToBooking).toHaveBeenCalledWith(4021, { mileruserid: 91 });
});
it('should not send the milerprofileid, which this route does not accept', async () => {
endpoints.assignMilerToBooking.mockResolvedValue({ success: true });
await changeRiderAPI({ userid: 91, milerprofileid: 11 }, { orderheaderid: 4021 });
const [, body] = endpoints.assignMilerToBooking.mock.calls[0];
expect(body).not.toHaveProperty('milerid');
expect(body).not.toHaveProperty('milerprofileid');
});
it('should fall back to deliveryid for the booking id', async () => {
endpoints.assignMilerToBooking.mockResolvedValue({ success: true });
await changeRiderAPI({ userid: 91 }, { deliveryid: 4022 });
expect(endpoints.assignMilerToBooking).toHaveBeenCalledWith(4022, { mileruserid: 91 });
});
it('should surface a rejected assignment to the caller', async () => {
endpoints.assignMilerToBooking.mockRejectedValue({ message: 'miler unavailable', httpStatus: 409 });
await expect(changeRiderAPI({ userid: 91 }, { orderheaderid: 4021 })).rejects.toMatchObject({
httpStatus: 409
});
});
});
describe('updateDeliveryAPI', () => {
beforeEach(() => endpoints.updateConsignmentStatus.mockResolvedValue({ success: true }));
it.each([
['started', 'Out_for_Delivery'],
['active', 'Out_for_Delivery'],
['delivered', 'Delivered'],
['cancelled', 'Cancelled'],
['canceled', 'Cancelled']
])('should translate the dialog choice %s to the consignment enum %s', async (chosen, sent) => {
await updateDeliveryAPI({ consignmentid: 40, orderstatus: chosen });
expect(endpoints.updateConsignmentStatus).toHaveBeenCalledWith(40, { status: sent });
});
it('should be case-insensitive about the dialog choice', async () => {
await updateDeliveryAPI({ consignmentid: 40, orderstatus: 'Delivered' });
expect(endpoints.updateConsignmentStatus).toHaveBeenCalledWith(40, { status: 'Delivered' });
});
it('should target the CONSIGNMENT id, not the booking id', async () => {
// deliveryid is always the bookingid, so `deliveryid ?? consignmentid`
// never fell through — every submit hit the endpoint with the wrong id.
await updateDeliveryAPI({ consignmentid: 40, deliveryid: 4021, orderstatus: 'delivered' });
expect(endpoints.updateConsignmentStatus).toHaveBeenCalledWith(40, expect.anything());
});
it('should send only `status` — the other dialog fields have no field here', async () => {
// Forwarding the whole dialog state produced 400 {"status is required"}.
await updateDeliveryAPI({
consignmentid: 40,
orderstatus: 'delivered',
deliveryamt: 240,
cumulativekms: 12,
userid: 91,
notes: 'left with security'
});
const [, body] = endpoints.updateConsignmentStatus.mock.calls[0];
expect(body).toEqual({ status: 'Delivered' });
});
it('should refuse with a prompt when no status was chosen', async () => {
await expect(updateDeliveryAPI({ consignmentid: 40, orderstatus: '' })).resolves.toEqual({
success: false,
message: 'Choose a status first.'
});
expect(endpoints.updateConsignmentStatus).not.toHaveBeenCalled();
});
it.each([
['picked', 'already a consignment'],
['pending', 'only exists after pickup'],
['accepted', 'only exists after pickup'],
['arrived', 'nothing to set'],
['skipped', 'attempt count']
])('should refuse %s with a specific reason rather than guessing a status', async (chosen, fragment) => {
const result = await updateDeliveryAPI({ consignmentid: 40, orderstatus: chosen });
expect(result.success).toBe(false);
expect(result.message).toContain(fragment);
expect(endpoints.updateConsignmentStatus).not.toHaveBeenCalled();
});
it('should refuse an unrecognised status naming the value that was rejected', async () => {
const result = await updateDeliveryAPI({ consignmentid: 40, orderstatus: 'teleported' });
expect(result.success).toBe(false);
expect(result.message).toContain('teleported');
expect(endpoints.updateConsignmentStatus).not.toHaveBeenCalled();
});
it('should never write a pre-pickup booking state to the consignment endpoint', async () => {
await Promise.all(
['picked', 'pending', 'accepted', 'arrived', 'skipped'].map((orderstatus) =>
updateDeliveryAPI({ consignmentid: 40, orderstatus })
)
);
expect(endpoints.updateConsignmentStatus).not.toHaveBeenCalled();
});
it('should surface a rejected status write to the caller', async () => {
endpoints.updateConsignmentStatus.mockRejectedValue({ message: 'status is required', httpStatus: 400 });
await expect(updateDeliveryAPI({ consignmentid: 40, orderstatus: 'delivered' })).rejects.toMatchObject({
httpStatus: 400
});
});
});
describe('getorderdetails', () => {
it('should read a single booking by id', async () => {
endpoints.getBooking.mockResolvedValue({ bookingid: 4021 });
await expect(getorderdetails(4021)).resolves.toEqual({ bookingid: 4021 });
expect(endpoints.getBooking).toHaveBeenCalledWith(4021);
});
});
});

View File

@@ -0,0 +1,234 @@
jest.mock('@/api/doormile/endpoints', () => ({ __esModule: true }));
jest.mock('@/api/doormile/notify', () => ({ __esModule: true, OpenToast: jest.fn(), messageOf: jest.fn() }));
import { deriveDeliveryStatus, mapBookingStatusToDeliveryStatus } from '@/api/doormile/queries';
/**
* Raw booking/consignment enum -> the Deliveries page's lifecycle key.
*
* This is the single most consequential mapping in the console: it decides
* which tab an order appears under, what the chip counts say, and what badge
* the row renders. Three rules in it are explicit product direction rather than
* convenience, and each has its own test below:
*
* 1. Miler_Assigned stays PENDING. Assigning is an OPERATOR action; Accepted
* must reflect the RIDER's own action.
* 2. Converted_To_Consignment is PICKED, not accepted — the parcel is in the
* rider's hands the moment the booking converts.
* 3. Once a consignment exists, ITS status wins: the booking's own status
* stops moving after conversion, so reading only the booking left an
* updated row showing a stale Converted_To_Consignment.
*/
describe('deriveDeliveryStatus', () => {
describe('called with a bare status string', () => {
it.each([
['pending_pickup', 'pending'],
['pending_assignment', 'pending'],
['pending', 'pending'],
['miler_assigned', 'pending'],
['pickup_scheduled', 'accepted'],
['arrived', 'arrived'],
['converted_to_consignment', 'picked'],
['collected_by_miler', 'picked'],
['collected', 'picked'],
['picked', 'picked'],
['out_for_delivery', 'active'],
['in_transit', 'active'],
['intransit', 'active'],
['inwarded_at_hub', 'active'],
['inwarded', 'active'],
['at_hub', 'active'],
['outwarded_from_hub', 'active'],
['in_transit_to_hub', 'active'],
['active', 'active'],
['delivered', 'delivered'],
['completed', 'delivered'],
['cancelled', 'cancelled'],
['canceled', 'cancelled'],
['returned', 'cancelled'],
['failed', 'cancelled'],
['skipped', 'skipped'],
['rto', 'skipped']
])('should map %s to %s', (raw, expected) => {
expect(deriveDeliveryStatus(raw)).toBe(expected);
});
it('should match the backend enum regardless of its casing', () => {
expect(deriveDeliveryStatus('Out_for_Delivery')).toBe('active');
expect(deriveDeliveryStatus('CONVERTED_TO_CONSIGNMENT')).toBe('picked');
});
it('should tolerate surrounding whitespace', () => {
expect(deriveDeliveryStatus(' Delivered ')).toBe('delivered');
});
it('should pass an unmapped enum through lowercased rather than dropping the row', () => {
// The page surfaces these under an "Other" tab naming the enum; before
// that, such a row was counted nowhere and shown nowhere.
expect(deriveDeliveryStatus('Awaiting_Customs_Clearance')).toBe('awaiting_customs_clearance');
});
it.each([null, undefined, '', 0, false])(
'should default the blank status %p to pending',
(raw) => {
expect(deriveDeliveryStatus(raw)).toBe('pending');
}
);
});
describe('the operator-vs-rider split (product rule)', () => {
it('should keep Miler_Assigned on PENDING — assigning is an operator action', () => {
// Bumping this to 'accepted' made a just-assigned, not-yet-acknowledged
// order look already-accepted. Explicit product requirement.
expect(deriveDeliveryStatus('miler_assigned')).toBe('pending');
expect(deriveDeliveryStatus({ status: 'Miler_Assigned' })).toBe('pending');
});
it('should treat Pickup_Scheduled as the rider having accepted', () => {
expect(deriveDeliveryStatus('pickup_scheduled')).toBe('accepted');
});
it('should treat Converted_To_Consignment as PICKED, not accepted', () => {
// It sat on 'accepted', which held a picked-up parcel in the Accepted tab
// and left Picked permanently empty.
expect(deriveDeliveryStatus('converted_to_consignment')).toBe('picked');
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment' })).toBe('picked');
});
});
describe('Pickup_Scheduled combined with a reached timestamp', () => {
it.each([
['2026-08-27T10:15:00', 'a real timestamp'],
['0', 'a zero-ish string that is still a value'],
[1, 'an epoch-like number']
])('should report ARRIVED when reachedAt is %p (%s)', (reachedAt) => {
expect(deriveDeliveryStatus('pickup_scheduled', reachedAt)).toBe('arrived');
});
it.each([null, undefined, false, ''])(
'should report ACCEPTED when reachedAt is the blank value %p',
(reachedAt) => {
expect(deriveDeliveryStatus('pickup_scheduled', reachedAt)).toBe('accepted');
}
);
it('should read the reached timestamp off a row under any of its field spellings', () => {
['reachedat', 'reached_at', 'reachedAt', 'reachedtime', 'reached_time'].forEach((field) => {
expect(deriveDeliveryStatus({ status: 'Pickup_Scheduled', [field]: '2026-08-27T10:15:00' })).toBe('arrived');
});
});
it('should report ACCEPTED for a Pickup_Scheduled row with no reached field at all', () => {
expect(deriveDeliveryStatus({ status: 'Pickup_Scheduled' })).toBe('accepted');
});
});
describe('called with a booking row', () => {
it('should read the status from `status`, `bookingstatus` or `orderstatus`', () => {
expect(deriveDeliveryStatus({ status: 'Delivered' })).toBe('delivered');
expect(deriveDeliveryStatus({ bookingstatus: 'Delivered' })).toBe('delivered');
expect(deriveDeliveryStatus({ orderstatus: 'Delivered' })).toBe('delivered');
});
it('should prefer `status` over the other two spellings', () => {
expect(deriveDeliveryStatus({ status: 'Delivered', orderstatus: 'Cancelled' })).toBe('delivered');
});
it('should default a row with no status at all to pending', () => {
expect(deriveDeliveryStatus({ bookingid: 4021 })).toBe('pending');
});
});
describe('consignment status precedence', () => {
it('should let the consignment status attached to the row win over the booking status', () => {
// The booking stops moving once it converts; PUT /consignments/:id/status
// writes on the consignment, so reading only the booking showed a stale
// Converted_To_Consignment after a successful Update Status.
const row = { status: 'Converted_To_Consignment', consignmentstatus: 'Delivered' };
expect(deriveDeliveryStatus(row)).toBe('delivered');
});
it('should accept the snake_case spelling of the attached status', () => {
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment', consignment_status: 'Out_for_Delivery' })).toBe('active');
});
it('should resolve the consignment through a joined map when the row carries only an id', () => {
const map = new Map([['40', { status: 'Out_for_Delivery' }]]);
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment', consignmentid: 40 }, map)).toBe('active');
});
it.each(['status', 'consignmentstatus', 'currentstatus', 'consignment_status'])(
'should read the joined record status from its `%s` field',
(field) => {
const map = new Map([['40', { [field]: 'Delivered' }]]);
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment', consignmentid: 40 }, map)).toBe('delivered');
}
);
it('should fall back to the booking status when the joined map has no such consignment', () => {
const map = new Map([['99', { status: 'Delivered' }]]);
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment', consignmentid: 40 }, map)).toBe('picked');
});
it('should fall back to the booking status when the consignment status is unrecognised', () => {
// Degrades to exactly the behaviour from before the join existed.
const row = { status: 'Converted_To_Consignment', consignmentstatus: 'Held_At_Customs' };
expect(deriveDeliveryStatus(row)).toBe('picked');
});
it.each(['', ' ', null, undefined])(
'should ignore a blank attached consignment status (%p)',
(consignmentstatus) => {
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment', consignmentstatus })).toBe('picked');
}
);
it('should ignore a second argument that is not a Map', () => {
// The signature is overloaded (reachedAt | consignmentMap); a plain object
// must not be mistaken for a lookup table.
const notAMap = { 40: { status: 'Delivered' } };
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment', consignmentid: 40 }, notAMap)).toBe('picked');
});
it('should still return picked for a Collected_By_Miler booking with no consignment', () => {
expect(deriveDeliveryStatus({ status: 'Collected_By_Miler' })).toBe('picked');
});
});
describe('mapBookingStatusToDeliveryStatus', () => {
it('should be a straight alias so no second copy of the mapping can drift', () => {
const cases = [
['pickup_scheduled', '2026-08-27T10:00:00'],
['miler_assigned', undefined],
[{ status: 'Converted_To_Consignment', consignmentstatus: 'Delivered' }, undefined]
];
cases.forEach(([input, second]) => {
expect(mapBookingStatusToDeliveryStatus(input, second)).toBe(deriveDeliveryStatus(input, second));
});
});
});
describe('classification invariants', () => {
it('should be deterministic — the same input always yields the same tab', () => {
const row = { status: 'Pickup_Scheduled', reachedat: '2026-08-27T10:00:00' };
const results = new Set(Array.from({ length: 5 }, () => deriveDeliveryStatus(row)));
expect(results.size).toBe(1);
});
it('should never return undefined or an empty string for any input shape', () => {
const inputs = [null, undefined, '', 0, {}, { status: '' }, 'unknown_enum', { consignmentstatus: 'x' }];
inputs.forEach((input) => {
const result = deriveDeliveryStatus(input);
expect(typeof result).toBe('string');
expect(result).not.toBe('');
});
});
it('should never mutate the row it classifies', () => {
const row = { status: 'Pickup_Scheduled', reachedat: '2026-08-27T10:00:00' };
const snapshot = JSON.parse(JSON.stringify(row));
deriveDeliveryStatus(row);
expect(row).toEqual(snapshot);
});
});
});

378
tests/api/endpoints.test.js Normal file
View File

@@ -0,0 +1,378 @@
/**
* The one-function-per-route admin API layer.
*
* The transport is mocked (there is no real backend in a unit run), but nothing
* else is: the URL built, the query string assembled, the body sent, the
* envelope unwrapped and the pagination draining are all the behaviour under
* test. A wrong URL or a wrong body key here is a production bug that no page
* test would catch.
*/
jest.mock('@/api/doormile/client', () => ({
__esModule: true,
default: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn() },
DOORMILE_TOKEN_KEY: 'doormileToken',
DOORMILE_USER_KEY: 'doormileUser',
clearStoredSession: jest.fn()
}));
const clientModule = jest.requireMock('@/api/doormile/client');
const mockClient = clientModule.default;
const mockClearStoredSession = clientModule.clearStoredSession;
import {
loginAdmin,
logoutAdmin,
getDashboard,
getReports,
getLocationsSummary,
getProfile,
updateProfilePassword,
getAdminCustomers,
createAdminCustomer,
updateAdminCustomer,
getHubs,
createHub,
deleteHub,
getMilers,
notifyMiler,
getMilerLogs,
getBookings,
getBookingsPage,
getBooking,
assignMilerToBooking,
assignVehicleToBooking,
batchAssignBookings,
cancelBooking,
bulkCancelBookings,
getConsignments,
updateConsignmentStatus,
getTenantLocations
} from '@/api/doormile/endpoints';
/** `{ data: { data: rows, total } }` — the list envelope the backend returns. */
const listPage = (rows, total) => ({ data: { success: true, data: rows, total } });
const rowsOfSize = (n, offset = 0) => Array.from({ length: n }, (_, i) => ({ id: offset + i + 1 }));
describe('doormile endpoints', () => {
describe('loginAdmin', () => {
it('should post the credentials to the admin login route', async () => {
mockClient.post.mockResolvedValue({ data: { success: true, token: 't', user: { userid: 1 } } });
await loginAdmin('ops@doormile.com', 'hunter2');
expect(mockClient.post).toHaveBeenCalledWith('/admin/login', {
email: 'ops@doormile.com',
password: 'hunter2'
});
});
it('should store the token and the admin record on success', async () => {
mockClient.post.mockResolvedValue({ data: { success: true, token: 'jwt-abc', user: { userid: 7, tenantid: '0' } } });
const result = await loginAdmin('ops@doormile.com', 'hunter2');
expect(localStorage.getItem('doormileToken')).toBe('jwt-abc');
expect(JSON.parse(localStorage.getItem('doormileUser'))).toEqual({ userid: 7, tenantid: '0' });
expect(result).toEqual({ success: true, token: 'jwt-abc', user: { userid: 7, tenantid: '0' } });
});
it('should store nothing when the server refuses the credentials', async () => {
// A half-written session (token stored on a failed login) is worse than no
// session: the console renders and 401s on every fetch.
mockClient.post.mockResolvedValue({ data: { success: false, message: 'Invalid credentials' } });
const result = await loginAdmin('ops@doormile.com', 'wrong');
expect(localStorage.getItem('doormileToken')).toBeNull();
expect(localStorage.getItem('doormileUser')).toBeNull();
expect(result).toEqual({ success: false, message: 'Invalid credentials' });
});
it('should propagate a transport failure to the caller', async () => {
mockClient.post.mockRejectedValue({ message: 'Network Error' });
await expect(loginAdmin('a@b.c', 'x')).rejects.toMatchObject({ message: 'Network Error' });
expect(localStorage.getItem('doormileToken')).toBeNull();
});
});
describe('logoutAdmin', () => {
it('should clear the whole stored session, not just the token', () => {
logoutAdmin();
expect(mockClearStoredSession).toHaveBeenCalledTimes(1);
});
});
describe('envelope unwrapping', () => {
it('should return response.data.data for a GET', async () => {
mockClient.get.mockResolvedValue({ data: { success: true, data: { totalOrders: 12 } } });
await expect(getDashboard()).resolves.toEqual({ totalOrders: 12 });
});
it('should return the full response.data for a mutation, so callers can read success', async () => {
mockClient.post.mockResolvedValue({ data: { success: true, message: 'Hub created' } });
await expect(createHub({ hubname: 'Koramangala' })).resolves.toEqual({
success: true,
message: 'Hub created'
});
});
it('should return the parsed profile', async () => {
mockClient.get.mockResolvedValue({ data: { data: { userid: 7 } } });
await expect(getProfile()).resolves.toEqual({ userid: 7 });
expect(mockClient.get).toHaveBeenCalledWith('/admin/profile');
});
});
describe('query-string building', () => {
beforeEach(() => mockClient.get.mockResolvedValue({ data: { data: [] } }));
it('should include every supplied report filter', async () => {
await getReports('2026-08-01', '2026-08-27', '4', '9', '2');
expect(mockClient.get).toHaveBeenCalledWith(
'/admin/reports?from=2026-08-01&to=2026-08-27&tenantid=4&locationid=9&hubid=2'
);
});
it('should omit undefined, null and empty filters rather than sending blanks', async () => {
// `?tenantid=` is not the same request as no tenant filter — one asks the
// backend to match the empty string.
await getReports('2026-08-01', '2026-08-27', undefined, null, '');
expect(mockClient.get).toHaveBeenCalledWith('/admin/reports?from=2026-08-01&to=2026-08-27');
});
it('should produce no query string at all when every filter is absent', async () => {
await getReports();
expect(mockClient.get).toHaveBeenCalledWith('/admin/reports');
});
it('should keep a zero filter value, which is a real id', async () => {
await getLocationsSummary(0, 0, '2026-08-01', '2026-08-27');
expect(mockClient.get).toHaveBeenCalledWith(
expect.stringContaining('tenantid=0')
);
});
it('should url-encode a filter value', async () => {
await getMilerLogs(3, '2026-08-01 00:00', '2026-08-27 23:59', 50);
const url = mockClient.get.mock.calls[0][0];
expect(url).toContain('/admin/milers/3/logs?');
expect(url).not.toMatch(/ /);
});
});
describe('fetchAllPages (getAdminCustomers)', () => {
it('should return a single page when it already holds every row', async () => {
mockClient.get.mockResolvedValueOnce(listPage(rowsOfSize(12), 12));
await expect(getAdminCustomers()).resolves.toHaveLength(12);
expect(mockClient.get).toHaveBeenCalledTimes(1);
expect(mockClient.get).toHaveBeenCalledWith('/admin/customers?pageno=1&pagesize=100');
});
it('should drain every page rather than silently truncating the list', async () => {
// The regression this protects: 37 customers came back as 20, and the
// Customers page reported "Total Customers 20" with no way to tell.
mockClient.get
.mockResolvedValueOnce(listPage(rowsOfSize(100), 237))
.mockResolvedValueOnce(listPage(rowsOfSize(100, 100), 237))
.mockResolvedValueOnce(listPage(rowsOfSize(37, 200), 237));
await expect(getAdminCustomers()).resolves.toHaveLength(237);
expect(mockClient.get).toHaveBeenCalledTimes(3);
expect(mockClient.get).toHaveBeenNthCalledWith(3, '/admin/customers?pageno=3&pagesize=100');
});
it('should stop early when a page comes back empty', async () => {
mockClient.get
.mockResolvedValueOnce(listPage(rowsOfSize(100), 500))
.mockResolvedValueOnce(listPage([], 500));
await expect(getAdminCustomers()).resolves.toHaveLength(100);
expect(mockClient.get).toHaveBeenCalledTimes(2);
});
it('should cap the drain so a wrong `total` cannot spin forever', async () => {
mockClient.get.mockResolvedValue(listPage(rowsOfSize(100), 10_000_000));
await getAdminCustomers();
expect(mockClient.get.mock.calls.length).toBeLessThanOrEqual(12);
});
it('should return the first page when the envelope carries no usable total', async () => {
mockClient.get.mockResolvedValueOnce({ data: { data: rowsOfSize(20) } });
await expect(getAdminCustomers()).resolves.toHaveLength(20);
expect(mockClient.get).toHaveBeenCalledTimes(1);
});
it('should return an empty array — not undefined — for an empty resource', async () => {
mockClient.get.mockResolvedValueOnce({ data: { data: null, total: 0 } });
await expect(getAdminCustomers()).resolves.toEqual([]);
});
it('should propagate a failure on the first page', async () => {
mockClient.get.mockRejectedValueOnce({ message: 'boom', httpStatus: 500 });
await expect(getAdminCustomers()).rejects.toMatchObject({ message: 'boom' });
});
});
describe('getBookings', () => {
it('should drain all pages when the first page is full', async () => {
mockClient.get
.mockResolvedValueOnce(listPage(rowsOfSize(100), 150))
.mockResolvedValueOnce(listPage(rowsOfSize(50, 100), 150));
await expect(getBookings(1, 100)).resolves.toHaveLength(150);
});
it('should return the first page when it is not full', async () => {
mockClient.get.mockResolvedValueOnce(listPage(rowsOfSize(37), 37));
await expect(getBookings(1, 100)).resolves.toHaveLength(37);
expect(mockClient.get).toHaveBeenCalledTimes(1);
});
it('should honour an explicit small page beyond the first', async () => {
mockClient.get.mockResolvedValueOnce(listPage(rowsOfSize(25, 25), 200));
await expect(getBookings(2, 25)).resolves.toHaveLength(25);
expect(mockClient.get).toHaveBeenCalledWith('/admin/bookings?pageno=2&pagesize=25');
});
it('should stop draining rather than reject when a later page fails', async () => {
// Partial rows beat a blank Deliveries page.
mockClient.get
.mockResolvedValueOnce(listPage(rowsOfSize(100), 300))
.mockRejectedValueOnce({ message: 'gateway timeout' });
await expect(getBookings(1, 100)).resolves.toHaveLength(100);
});
it('should return an empty array for an account with no bookings', async () => {
mockClient.get.mockResolvedValueOnce({ data: { data: [], total: 0 } });
await expect(getBookings(1, 100)).resolves.toEqual([]);
});
});
describe('getBookingsPage', () => {
it('should keep the envelope total instead of throwing it away', async () => {
mockClient.get.mockResolvedValue(listPage(rowsOfSize(50), 1234));
await expect(getBookingsPage(1, 50)).resolves.toEqual({
rows: expect.any(Array),
total: 1234,
page: 1
});
});
it('should fall back to the row count when the backend omits total', async () => {
// Degrading to "this is everything" beats NaN-driven pagination.
mockClient.get.mockResolvedValue({ data: { data: rowsOfSize(7) } });
await expect(getBookingsPage(1, 50)).resolves.toMatchObject({ total: 7, page: 1 });
});
it('should fall back to the requested page number when the backend omits page', async () => {
mockClient.get.mockResolvedValue({ data: { data: [], total: 0 } });
await expect(getBookingsPage(4, 50)).resolves.toMatchObject({ page: 4 });
});
});
describe('booking write paths', () => {
beforeEach(() => mockClient.post.mockResolvedValue({ data: { success: true } }));
it('should assign a miler through the booking-scoped assign route', async () => {
await assignMilerToBooking(4021, { mileruserid: 91 });
expect(mockClient.post).toHaveBeenCalledWith('/admin/bookings/4021/assign-miler', { mileruserid: 91 });
});
it('should cancel through the booking-level cancel action, not a status PUT', async () => {
await cancelBooking(4021, { reason: 'customer unreachable' });
expect(mockClient.post).toHaveBeenCalledWith('/admin/bookings/4021/cancel', {
reason: 'customer unreachable'
});
expect(mockClient.put).not.toHaveBeenCalled();
});
it('should assign a vehicle through its own route', async () => {
await assignVehicleToBooking(4021, { vehicleid: 5 });
expect(mockClient.post).toHaveBeenCalledWith('/admin/bookings/4021/assign-vehicle', { vehicleid: 5 });
});
it('should batch-assign with a default cap of five stops per rider', async () => {
await batchAssignBookings([1, 2, 3]);
expect(mockClient.post).toHaveBeenCalledWith('/hub/bookings/batch-assign', {
bookingids: [1, 2, 3],
max_per_rider: 5
});
});
it('should honour an explicit per-rider cap', async () => {
await batchAssignBookings([1, 2], 2);
expect(mockClient.post).toHaveBeenCalledWith('/hub/bookings/batch-assign', {
bookingids: [1, 2],
max_per_rider: 2
});
});
it('should bulk-cancel through the dedicated route', async () => {
await bulkCancelBookings([1, 2, 3]);
expect(mockClient.post).toHaveBeenCalledWith('/admin/bookings/bulk-cancel', { bookingIds: [1, 2, 3] });
});
it('should surface a rejected write to the caller', async () => {
mockClient.post.mockRejectedValue({ message: 'booking already cancelled', httpStatus: 409 });
await expect(cancelBooking(4021, {})).rejects.toMatchObject({ httpStatus: 409 });
});
});
describe('consignment write path', () => {
it('should PUT the status to the consignment-scoped route', async () => {
mockClient.put.mockResolvedValue({ data: { success: true } });
await updateConsignmentStatus(40, { status: 'Delivered' });
expect(mockClient.put).toHaveBeenCalledWith('/admin/consignments/40/status', { status: 'Delivered' });
});
it('should read the consignment list from its own resource', async () => {
mockClient.get.mockResolvedValue({ data: { data: [{ consignmentid: 40 }] } });
await expect(getConsignments()).resolves.toEqual([{ consignmentid: 40 }]);
expect(mockClient.get).toHaveBeenCalledWith('/admin/consignments');
});
});
describe('resource routes', () => {
beforeEach(() => {
mockClient.get.mockResolvedValue({ data: { data: [] } });
mockClient.post.mockResolvedValue({ data: { success: true } });
mockClient.patch.mockResolvedValue({ data: { success: true } });
mockClient.delete.mockResolvedValue({ data: { success: true } });
});
it.each([
['getHubs', () => getHubs(), '/admin/hubs'],
['getMilers', () => getMilers(), '/admin/milers'],
['getBooking', () => getBooking(4021), '/admin/bookings/4021'],
['getTenantLocations', () => getTenantLocations(4), '/admin/tenants/4/locations']
])('%s should read from %s', async (_name, call, url) => {
await call();
expect(mockClient.get).toHaveBeenCalledWith(url);
});
it('should create a customer with POST and update one with PATCH', async () => {
await createAdminCustomer({ customername: 'Anita' });
expect(mockClient.post).toHaveBeenCalledWith('/admin/customers', { customername: 'Anita' });
await updateAdminCustomer(9, { phone: '9876543210' });
expect(mockClient.patch).toHaveBeenCalledWith('/admin/customers/9', { phone: '9876543210' });
});
it('should delete a hub through its own id-scoped route', async () => {
await deleteHub(3);
expect(mockClient.delete).toHaveBeenCalledWith('/admin/hubs/3');
});
it('should notify a miler by milerprofileid with a title and message', async () => {
await notifyMiler(11, 'New assignment', 'Order 4021 assigned to you');
expect(mockClient.post).toHaveBeenCalledWith(
expect.stringContaining('/admin/milers/11/notify'),
expect.objectContaining({ title: 'New assignment', message: 'Order 4021 assigned to you' })
);
});
it('should change a password through the profile route with both fields', async () => {
mockClient.put.mockResolvedValue({ data: { success: true } });
await updateProfilePassword('old-secret', 'new-secret');
const [, body] = mockClient.put.mock.calls[0];
expect(body).toMatchObject({ current_password: 'old-secret', new_password: 'new-secret' });
});
});
});

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

View File

@@ -0,0 +1,28 @@
/**
* Rewrites `import.meta` so Babel can compile Vite source to CommonJS for Jest.
*
* `import.meta` is only legal inside a real ES module; babel-jest compiles to
* CJS, so every file that reads `import.meta.env` (api/doormile/client.js,
* lib/distance.js) is a hard parse error without this.
*
* It resolves to `globalThis.__VITE_IMPORT_META__` — a plain object the test
* setup seeds and individual tests can override, which is the seam that lets a
* test exercise `VITE_DOORMILE_URL` / `VITE_OSRM_URL` overrides at all.
*/
module.exports = function importMetaPlugin({ types: t }) {
return {
name: 'transform-import-meta-for-jest',
visitor: {
MetaProperty(path) {
if (path.node.meta.name !== 'import' || path.node.property.name !== 'meta') return;
path.replaceWith(
t.logicalExpression(
'||',
t.memberExpression(t.identifier('globalThis'), t.identifier('__VITE_IMPORT_META__')),
t.objectExpression([t.objectProperty(t.identifier('env'), t.objectExpression([]))])
)
);
},
},
};
};

1
tests/setup/fileMock.cjs Normal file
View File

@@ -0,0 +1 @@
module.exports = 'test-file-stub';

View File

@@ -0,0 +1,18 @@
require('@testing-library/jest-dom');
/**
* The `import.meta.env` stand-in (see babel-plugin-import-meta.cjs).
*
* Defaults to production-shaped values — no VITE_ overrides, DEV false — so a
* module that branches on an override only does so in the test that asks for
* it. Reset before every test so one test's override cannot leak into the next.
*/
const defaultImportMeta = () => ({ env: { DEV: false, PROD: true, MODE: 'test' } });
globalThis.__VITE_IMPORT_META__ = defaultImportMeta();
beforeEach(() => {
globalThis.__VITE_IMPORT_META__ = defaultImportMeta();
localStorage.clear();
jest.clearAllMocks();
});

View File

@@ -0,0 +1,2 @@
// CSS/asset imports carry no behaviour worth asserting; Jest cannot parse them.
module.exports = {};

View File

@@ -0,0 +1,160 @@
import { isNumber, isLowercaseChar, isUppercaseChar, isSpecialChar, minLength } from '@/utils/password-validation';
import { strengthColor, strengthIndicator } from '@/utils/password-strength';
/**
* Password rules for the login and change-password forms.
*
* These gate what the console will accept as a credential, so the boundaries
* matter more than the happy path: a rule that is one character loose is a
* weaker password policy than the UI claims to enforce.
*/
describe('password-validation', () => {
describe('character-class rules', () => {
it.each([
['isNumber', isNumber, 'abc1', 'abcd'],
['isLowercaseChar', isLowercaseChar, 'ABCd', 'ABCD'],
['isUppercaseChar', isUppercaseChar, 'abcD', 'abcd'],
['isSpecialChar', isSpecialChar, 'abc!', 'abcd']
])('%s should accept a qualifying value and reject one without it', (_name, rule, pass, fail) => {
expect(rule(pass)).toBe(true);
expect(rule(fail)).toBe(false);
});
it('should accept a digit anywhere in the value, not only at the end', () => {
expect(isNumber('1abc')).toBe(true);
expect(isNumber('ab1c')).toBe(true);
});
it.each(['-', '+', '_', '!', '@', '#', '$', '%', '^', '&', '*', '.', ',', '?'])(
'should treat %s as a special character',
(char) => {
expect(isSpecialChar(`abc${char}`)).toBe(true);
}
);
it.each(['(', ')', '=', '/', '\\', '~', '|', '<', '>', '[', ']', '{', '}', ';', ':', "'", '"'])(
'should NOT treat %s as a special character under the current rule set',
(char) => {
// Documents the accepted set precisely: a user typing a symbol outside
// it is told their password has no special character.
expect(isSpecialChar(`abc${char}`)).toBe(false);
}
);
it.each([isNumber, isLowercaseChar, isUppercaseChar, isSpecialChar])(
'should reject an empty value',
(rule) => {
expect(rule('')).toBe(false);
}
);
});
describe('minLength', () => {
it('should reject a value of exactly eight characters minus one', () => {
expect(minLength('1234567')).toBe(false);
});
it('should accept a value of eight characters', () => {
// The boundary the UI advertises as "at least 8 characters".
expect(minLength('12345678')).toBe(true);
});
it('should accept anything longer', () => {
expect(minLength('123456789')).toBe(true);
});
it('should reject an empty value', () => {
expect(minLength('')).toBe(false);
});
});
});
describe('password-strength', () => {
describe('strengthIndicator', () => {
it('should score an empty password at zero', () => {
expect(strengthIndicator('')).toBe(0);
});
it('should score a short lowercase-only password at zero', () => {
expect(strengthIndicator('abcde')).toBe(0);
});
it('should award a point once the password passes five characters', () => {
expect(strengthIndicator('abcdef')).toBe(1);
});
it('should award a second point once it passes seven characters', () => {
expect(strengthIndicator('abcdefgh')).toBe(2);
});
it('should award a point for a digit', () => {
expect(strengthIndicator('abcde1')).toBe(2);
});
it('should award a point for a special character', () => {
expect(strengthIndicator('abcde!')).toBe(2);
});
it('should award a point only when BOTH cases are present', () => {
expect(strengthIndicator('abcdeF')).toBe(2);
expect(strengthIndicator('ABCDEF')).toBe(1);
});
it('should award every point for a long mixed password', () => {
expect(strengthIndicator('Abcdef1!')).toBe(5);
});
it('should never exceed five, which is the maximum the rules can award', () => {
// Worth pinning: strengthColor branches on `count < 6`, which is only
// reachable if this invariant is ever broken.
const samples = ['', 'a', 'Abcdef1!', 'A1!aaaaaaaaaaaaaaaaaaaaaaaaaa', '!!!!!!!!!!AAAAaaaa1111'];
samples.forEach((password) => {
expect(strengthIndicator(password)).toBeLessThanOrEqual(5);
expect(strengthIndicator(password)).toBeGreaterThanOrEqual(0);
});
});
});
describe('strengthColor', () => {
it.each([
[0, 'Poor'],
[1, 'Poor'],
[2, 'Weak'],
[3, 'Normal'],
[4, 'Good'],
[5, 'Strong']
])('should label a score of %d as %s', (count, label) => {
expect(strengthColor(count).label).toBe(label);
});
it('should return a colour token alongside every label', () => {
[0, 1, 2, 3, 4, 5].forEach((count) => {
expect(strengthColor(count).color).toEqual(expect.any(String));
expect(strengthColor(count).color).not.toBe('');
});
});
it('should escalate the label monotonically as the score rises', () => {
const ranked = ['Poor', 'Weak', 'Normal', 'Good', 'Strong'];
const labels = [0, 1, 2, 3, 4, 5].map((count) => strengthColor(count).label);
const indices = labels.map((label) => ranked.indexOf(label));
indices.slice(1).forEach((index, i) => expect(index).toBeGreaterThanOrEqual(indices[i]));
});
});
describe('the two modules together', () => {
it('should rate a password that satisfies every validation rule as Strong', () => {
const password = 'Abcdef1!';
expect(minLength(password)).toBe(true);
expect(isNumber(password)).toBe(true);
expect(isLowercaseChar(password)).toBe(true);
expect(isUppercaseChar(password)).toBe(true);
expect(isSpecialChar(password)).toBe(true);
expect(strengthColor(strengthIndicator(password)).label).toBe('Strong');
});
it('should rate a password that satisfies none of them as Poor', () => {
expect(strengthColor(strengthIndicator('abc')).label).toBe('Poor');
});
});
});

171
tests/utils/session.test.js Normal file
View File

@@ -0,0 +1,171 @@
import {
ACTIVITY_STORAGE_KEY,
SESSION_START_STORAGE_KEY,
AUTH_PRESENCE_KEY,
INACTIVITY_TIMEOUT_MS,
ABSOLUTE_SESSION_TIMEOUT_MS,
markActivity,
markSessionStart,
isSessionActive,
performSessionLogout
} from '@/utils/session';
/**
* The shared logout contract. Every logout path — the manual button and the
* inactivity timer — goes through here so they cannot clear different things.
*
* The security-relevant property is that local state is wiped and the page
* HARD-navigates even when a caller-supplied teardown throws: a half-completed
* logout that leaves an authenticated page rendered is the failure mode this
* module exists to prevent.
*/
describe('session', () => {
let replace;
beforeEach(() => {
replace = jest.fn();
Object.defineProperty(window, 'location', {
writable: true,
configurable: true,
value: { href: 'http://console.local/deliveries', replace }
});
});
describe('timeout policy', () => {
it('should expire an idle session after one hour', () => {
expect(INACTIVITY_TIMEOUT_MS).toBe(60 * 60 * 1000);
});
it('should cap a session at one hour since login regardless of activity', () => {
expect(ABSOLUTE_SESSION_TIMEOUT_MS).toBe(60 * 60 * 1000);
});
});
describe('markActivity', () => {
it('should record the current time as a millisecond string', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-08-27T10:00:00Z'));
markActivity();
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBe(String(Date.now()));
jest.useRealTimers();
});
it('should overwrite the previous activity stamp', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-08-27T10:00:00Z'));
markActivity();
const first = localStorage.getItem(ACTIVITY_STORAGE_KEY);
jest.setSystemTime(new Date('2026-08-27T10:05:00Z'));
markActivity();
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).not.toBe(first);
expect(Number(localStorage.getItem(ACTIVITY_STORAGE_KEY))).toBeGreaterThan(Number(first));
jest.useRealTimers();
});
});
describe('markSessionStart', () => {
it('should start the absolute-lifetime clock separately from the activity clock', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-08-27T10:00:00Z'));
markSessionStart();
expect(localStorage.getItem(SESSION_START_STORAGE_KEY)).toBe(String(Date.now()));
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull();
jest.useRealTimers();
});
});
describe('isSessionActive', () => {
it('should report an active session when the auth presence key is set', () => {
localStorage.setItem(AUTH_PRESENCE_KEY, 'ops@doormile.com');
expect(isSessionActive()).toBe(true);
});
it('should report no session when the key is absent', () => {
expect(isSessionActive()).toBe(false);
});
it('should report no session for an empty presence value', () => {
localStorage.setItem(AUTH_PRESENCE_KEY, '');
expect(isSessionActive()).toBe(false);
});
it('should return a boolean, not the stored string', () => {
localStorage.setItem(AUTH_PRESENCE_KEY, 'ops@doormile.com');
expect(typeof isSessionActive()).toBe('boolean');
});
});
describe('performSessionLogout', () => {
it('should wipe all local state and hard-navigate to login', () => {
localStorage.setItem('doormileToken', 'jwt-abc');
localStorage.setItem(AUTH_PRESENCE_KEY, 'ops@doormile.com');
localStorage.setItem('anything-else', 'x');
performSessionLogout();
expect(localStorage.length).toBe(0);
expect(replace).toHaveBeenCalledWith('/login');
});
it('should clear the react-query cache so no fetched data survives', () => {
const queryClient = { clear: jest.fn() };
performSessionLogout({ queryClient });
expect(queryClient.clear).toHaveBeenCalledTimes(1);
});
it('should dispatch the supplied teardown actions', () => {
const dispatch = jest.fn();
const clearFcmToken = jest.fn(() => ({ type: 'fcm/clear' }));
const logoutUser = jest.fn(() => ({ type: 'auth/logout' }));
performSessionLogout({ dispatch, clearFcmToken, logoutUser });
expect(dispatch).toHaveBeenCalledWith({ type: 'fcm/clear' });
expect(dispatch).toHaveBeenCalledWith({ type: 'auth/logout' });
});
it('should skip the redux teardown when no dispatch is supplied', () => {
const clearFcmToken = jest.fn();
expect(() => performSessionLogout({ clearFcmToken })).not.toThrow();
expect(clearFcmToken).not.toHaveBeenCalled();
});
it('should work with no arguments at all', () => {
expect(() => performSessionLogout()).not.toThrow();
expect(replace).toHaveBeenCalledWith('/login');
});
it('should still wipe storage and redirect when the cache teardown throws', () => {
// The security-critical guarantee: a broken teardown must not leave an
// authenticated page rendered over live local state.
localStorage.setItem('doormileToken', 'jwt-abc');
const queryClient = {
clear: jest.fn(() => {
throw new Error('cache blew up');
})
};
expect(() => performSessionLogout({ queryClient })).toThrow('cache blew up');
expect(localStorage.length).toBe(0);
expect(replace).toHaveBeenCalledWith('/login');
});
it('should still wipe storage and redirect when a dispatched action throws', () => {
localStorage.setItem('doormileToken', 'jwt-abc');
const dispatch = jest.fn(() => {
throw new Error('reducer blew up');
});
expect(() =>
performSessionLogout({ dispatch, logoutUser: () => ({ type: 'auth/logout' }) })
).toThrow('reducer blew up');
expect(localStorage.length).toBe(0);
expect(replace).toHaveBeenCalledWith('/login');
});
it('should navigate rather than route, so the back button cannot resurrect the console', () => {
performSessionLogout();
expect(replace).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledWith('/login');
});
});
});