updated the test
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -32,3 +32,6 @@ dist-ssr
|
||||
.env
|
||||
.vite
|
||||
base44/.app.jsonc
|
||||
|
||||
# Jest coverage output
|
||||
coverage/
|
||||
|
||||
40
jest.config.cjs
Normal file
40
jest.config.cjs
Normal 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
5246
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
322
tests/api/client.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
556
tests/api/deliveries.test.js
Normal file
556
tests/api/deliveries.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
234
tests/api/deliveryStatus.test.js
Normal file
234
tests/api/deliveryStatus.test.js
Normal 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
378
tests/api/endpoints.test.js
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
282
tests/lib/AuthContext.test.jsx
Normal file
282
tests/lib/AuthContext.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
401
tests/lib/ZoneContext.test.jsx
Normal file
401
tests/lib/ZoneContext.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
138
tests/lib/batchBucket.test.js
Normal file
138
tests/lib/batchBucket.test.js
Normal 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
179
tests/lib/distance.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
312
tests/lib/doormileFormat.test.js
Normal file
312
tests/lib/doormileFormat.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
133
tests/lib/doormileTimestamp.test.js
Normal file
133
tests/lib/doormileTimestamp.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
133
tests/lib/orderStatusGroups.test.js
Normal file
133
tests/lib/orderStatusGroups.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
159
tests/lib/preferences.test.js
Normal file
159
tests/lib/preferences.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
327
tests/lib/profitability.test.js
Normal file
327
tests/lib/profitability.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
28
tests/setup/babel-plugin-import-meta.cjs
Normal file
28
tests/setup/babel-plugin-import-meta.cjs
Normal 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
1
tests/setup/fileMock.cjs
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
||||
18
tests/setup/jest.setup.cjs
Normal file
18
tests/setup/jest.setup.cjs
Normal 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();
|
||||
});
|
||||
2
tests/setup/styleMock.cjs
Normal file
2
tests/setup/styleMock.cjs
Normal file
@@ -0,0 +1,2 @@
|
||||
// CSS/asset imports carry no behaviour worth asserting; Jest cannot parse them.
|
||||
module.exports = {};
|
||||
160
tests/utils/password.test.js
Normal file
160
tests/utils/password.test.js
Normal 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
171
tests/utils/session.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user