245 lines
11 KiB
JavaScript
245 lines
11 KiB
JavaScript
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');
|
|
});
|
|
|
|
it('should keep Converted_To_Consignment as picked when joined consignment only has generic status: Active', () => {
|
|
const map = new Map([['40', { status: 'Active' }]]);
|
|
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment', consignmentid: 40 }, map)).toBe('picked');
|
|
});
|
|
|
|
it('should bump Converted_To_Consignment to active when joined consignment has consignmentstatus: Out_for_Delivery', () => {
|
|
const map = new Map([['40', { status: 'Active', consignmentstatus: 'Out_for_Delivery' }]]);
|
|
expect(deriveDeliveryStatus({ status: 'Converted_To_Consignment', consignmentid: 40 }, map)).toBe('active');
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|