updated the test
This commit is contained in:
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user