Files
doormilxpress_astryx/tests/lib/orderFlow.test.js

481 lines
20 KiB
JavaScript

import {
ANCHOR_RADIUS_M,
DROP_TARGET,
FLOW,
PICKUP_SOURCE,
PICKUP_SOURCE_TYPE,
buildAnchors,
buildFlowFields,
classifyBooking,
flowForDraft,
flowLabel,
hasCustomerPickup,
hasHubDrop,
matchAnchor,
normalizeFlow
} from '@/lib/orderFlow';
/**
* Which end of a booking is a Doormile facility.
*
* The bug these tests exist to prevent: the console assumed the pickup was
* ALWAYS the hub. queries.js used the tenant's name as the collection point's
* identity, so every customer pickup for one client collapsed onto a single map
* pin placed at an arbitrary one of them, and the rider's polyline skipped the
* entire first-mile leg.
*
* Two invariants carry the most weight here and are asserted repeatedly:
*
* 1. UNKNOWABLE MEANS FORWARD. No anchors loaded, no coordinates, a 0,0
* "unset" — none of these may produce a customer pickup. An order that
* cannot be classified has to look exactly as it did before this module
* existed, or a data problem starts drawing phantom collection points.
* 2. A CUSTOMER PICKUP NEVER SHARES A GROUPING KEY. Two doorsteps are two
* places. Merging them is precisely the defect being replaced.
*/
// Real Coimbatore-ish coordinates, so the distances under test are the
// magnitudes the radius was actually chosen for.
const HUB = { hubid: 7, hubname: 'Gandhipuram Hub', latitude: 11.0168, longitude: 76.9558 };
const KITCHEN = {
tenantlocationid: 31,
tenantid: 4,
locationname: '124 Gandhipuram Main Rd',
latitude: 11.021,
longitude: 76.962
};
// ~4 km and ~6 km out — unambiguously somebody's door.
const DOOR_A = { lat: 11.05, lng: 77.0 };
const DOOR_B = { lat: 11.06, lng: 77.01 };
const anchors = buildAnchors({ hubs: [HUB], tenantLocations: [KITCHEN] });
const booking = ({ id = 1, from, to, ...rest }) => ({
bookingid: id,
pickuplatitude: from.lat,
pickuplongitude: from.lng,
deliverylatitude: to.lat,
deliverylongitude: to.lng,
...rest
});
const AT_HUB = { lat: HUB.latitude, lng: HUB.longitude };
const AT_KITCHEN = { lat: KITCHEN.latitude, lng: KITCHEN.longitude };
describe('orderFlow', () => {
describe('buildAnchors', () => {
it('should treat both a Doormile hub and a client location as facilities', () => {
expect(anchors).toHaveLength(2);
expect(anchors.map((a) => a.kind).sort()).toEqual(['hub', 'tenantlocation']);
});
it('should give each anchor a key that is unique per facility', () => {
expect(new Set(anchors.map((a) => a.key)).size).toBe(anchors.length);
});
it('should drop a facility nobody geocoded rather than keep it with NaN coordinates', () => {
const built = buildAnchors({ hubs: [{ hubid: 9, hubname: 'Unmapped' }, HUB] });
expect(built.map((a) => a.hubid)).toEqual([7]);
});
it('should return an empty set when called with nothing', () => {
expect(buildAnchors()).toEqual([]);
});
it('should give a tenant location the hub that services its city', () => {
// A tenant location record has NO hubid field — the API does not carry
// one. Without this, every order originating at a client's own kitchen
// went to the backend with no servicing hub, and zone attribution fell
// straight back to matching hub names against address text.
const kitchen = anchors.find((a) => a.kind === 'tenantlocation');
expect(kitchen.hubid).toBe(7);
});
it('should not invent a servicing hub for a location in another city', () => {
// Chennai, ~400 km away. A wrong hub id is worse than none: it files the
// order into a zone it has nothing to do with, where nobody is looking.
const [faraway] = buildAnchors({
hubs: [HUB],
tenantLocations: [{ tenantlocationid: 99, locationname: 'Chennai Depot', latitude: 13.0827, longitude: 80.2707 }]
}).filter((a) => a.kind === 'tenantlocation');
expect(faraway.hubid).toBeNull();
});
it("should keep a location's own hubid when the record already has one", () => {
const [explicit] = buildAnchors({
hubs: [HUB],
tenantLocations: [{ ...KITCHEN, hubid: 12 }]
}).filter((a) => a.kind === 'tenantlocation');
expect(explicit.hubid).toBe(12);
});
it('should leave hubid null when no hubs were loaded at all', () => {
const [alone] = buildAnchors({ tenantLocations: [KITCHEN] });
expect(alone.hubid).toBeNull();
});
});
describe('matchAnchor', () => {
it('should match a point sitting on a facility', () => {
expect(matchAnchor(HUB.latitude, HUB.longitude, anchors)?.hubid).toBe(7);
});
it('should not match a customer door several km away', () => {
expect(matchAnchor(DOOR_A.lat, DOOR_A.lng, anchors)).toBeNull();
});
it('should choose the NEAREST facility, not the first one in the array', () => {
// Two facilities sharing a compound is normal. Picking by array order
// would name the same pin differently depending on what /admin/hubs
// happened to return first.
const twinA = { hubid: 1, hubname: 'Sorting Centre', latitude: 11.0168, longitude: 76.9558 };
const twinB = { hubid: 2, hubname: 'Client Depot', latitude: 11.01685, longitude: 76.95585 };
const both = buildAnchors({ hubs: [twinA, twinB] });
expect(matchAnchor(twinB.latitude, twinB.longitude, both)?.hubid).toBe(2);
expect(matchAnchor(twinA.latitude, twinA.longitude, both)?.hubid).toBe(1);
});
it('should never match a 0,0 "unset" coordinate against a real facility', () => {
expect(matchAnchor(0, 0, anchors)).toBeNull();
});
it('should treat the radius as a boundary in metres, not degrees', () => {
// ~100 m north of the hub: inside. ~500 m: outside.
const inside = HUB.latitude + 0.0009;
const outside = HUB.latitude + 0.0045;
expect(matchAnchor(inside, HUB.longitude, anchors)).not.toBeNull();
expect(matchAnchor(outside, HUB.longitude, anchors)).toBeNull();
expect(ANCHOR_RADIUS_M).toBe(200);
});
});
describe('classifyBooking — the four flows', () => {
it('should classify hub to customer as Forward', () => {
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
expect(r.flow).toBe(FLOW.FORWARD);
expect(r.pickupSource).toBe(PICKUP_SOURCE.HUB);
expect(r.dropTarget).toBe(DROP_TARGET.CUSTOMER);
});
it('should classify customer to hub as Reverse — the customer pickup', () => {
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors);
expect(r.flow).toBe(FLOW.REVERSE);
expect(r.pickupSource).toBe(PICKUP_SOURCE.CUSTOMER);
expect(r.dropTarget).toBe(DROP_TARGET.HUB);
});
it('should classify customer to customer as P2P', () => {
const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors);
expect(r.flow).toBe(FLOW.P2P);
});
it('should classify hub to hub as Linehaul', () => {
const r = classifyBooking(booking({ from: AT_KITCHEN, to: AT_HUB }), anchors);
expect(r.flow).toBe(FLOW.LINEHAUL);
});
});
describe('classifyBooking — unknowable input stays Forward', () => {
it('should fall back to Forward when no facility list has loaded', () => {
// Geometry proves nothing without anchors. Anything but Forward here
// would relabel the whole board during the first render, before
// /admin/hubs has answered.
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), []).flow).toBe(FLOW.FORWARD);
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B })).flow).toBe(FLOW.FORWARD);
});
it('should fall back to Forward for a pickup that failed to geocode', () => {
// 0,0 is the backend's "unset". A pickup that never geocoded is a data
// problem, not a doorstep collection — calling it one would put a
// phantom violet pin on the map off the coast of Africa.
const unset = classifyBooking(booking({ id: 9, from: { lat: 0, lng: 0 }, to: DOOR_A }), anchors);
expect(unset.flow).toBe(FLOW.FORWARD);
const missing = classifyBooking({ bookingid: 10, deliverylatitude: DOOR_A.lat, deliverylongitude: DOOR_A.lng }, anchors);
expect(missing.flow).toBe(FLOW.FORWARD);
});
it('should not crash on a null or empty booking', () => {
expect(classifyBooking(null, anchors).flow).toBe(FLOW.FORWARD);
expect(classifyBooking({}, anchors).flow).toBe(FLOW.FORWARD);
});
});
describe('classifyBooking — an explicit ordertype wins over geometry', () => {
it('should trust a stored ordertype even when the coordinates disagree', () => {
// Coordinates say Forward; the record says otherwise. Once the backend
// persists the field it is the authority, because it records what the
// operator actually booked.
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A, ordertype: 'Reverse' }), anchors);
expect(r.flow).toBe(FLOW.REVERSE);
expect(r.derived).toBe(false);
});
it('should mark a geometry-decided classification as derived', () => {
expect(classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors).derived).toBe(true);
});
it('should fall through to geometry when the stored value means nothing', () => {
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB, ordertype: 'banana' }), anchors);
expect(r.flow).toBe(FLOW.REVERSE);
expect(r.derived).toBe(true);
});
});
describe('classifyBooking — the pickup point grouping key', () => {
it('should give every order off one facility the SAME key', () => {
const a = classifyBooking(booking({ id: 1, from: AT_KITCHEN, to: DOOR_A }), anchors);
const b = classifyBooking(booking({ id: 2, from: AT_KITCHEN, to: DOOR_B }), anchors);
expect(a.pickupPointKey).toBe(b.pickupPointKey);
});
it('should give every customer pickup its OWN key', () => {
// The defect being replaced: these two collapsed onto one map pin
// because both resolved to the tenant's name.
const a = classifyBooking(booking({ id: 1, from: DOOR_A, to: AT_HUB }), anchors);
const b = classifyBooking(booking({ id: 2, from: DOOR_B, to: AT_HUB }), anchors);
expect(a.pickupPointKey).not.toBe(b.pickupPointKey);
});
it('should name a facility pickup after the facility and a customer pickup after its address', () => {
const hub = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
expect(hub.pickupPointName).toBe('124 Gandhipuram Main Rd');
const door = classifyBooking(
booking({ from: DOOR_A, to: AT_HUB, pickupaddress: '22 Ponvizha Nagar' }),
anchors
);
expect(door.pickupPointName).toBe('22 Ponvizha Nagar');
});
});
describe('classifyBooking — servicing hub', () => {
it('should prefer an id the booking already carries', () => {
const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B, sourcehubid: 42 }), anchors);
expect(r.servicingHubId).toBe(42);
});
it('should fall back to the DESTINATION hub on a customer pickup', () => {
// Nothing at the pickup end names a facility, but an inbound collection
// plainly belongs to the hub it is being brought to. Without this, every
// customer pickup would lose its zone.
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors);
expect(r.servicingHubId).toBe(7);
});
it('should resolve through a tenant location, not only through a Doormile hub', () => {
// The case that was silently broken: the pickup matches the client's own
// kitchen, which is not itself a hub. Before the kitchen inherited a
// servicing hub this returned null and the order reached the backend
// with no zone.
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
expect(r.originAnchor.kind).toBe('tenantlocation');
expect(r.servicingHubId).toBe(7);
});
it('should be null on a P2P run where neither end is a facility', () => {
// Deliberate: a fabricated hub id here would file the order under a zone
// it has nothing to do with. Null lets ZoneContext fall back to radius.
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors).servicingHubId).toBeNull();
});
});
describe('normalizeFlow', () => {
it.each([
['reverse', FLOW.REVERSE],
['REVERSE', FLOW.REVERSE],
['customer_pickup', FLOW.REVERSE],
['customer pickup', FLOW.REVERSE],
['customer-pickup', FLOW.REVERSE],
['hub_pickup', FLOW.FORWARD],
['outbound', FLOW.FORWARD],
['p2p', FLOW.P2P],
['point_to_point', FLOW.P2P],
['hyperlocal', FLOW.P2P],
['line_haul', FLOW.LINEHAUL]
])('should read %s as %s', (input, expected) => {
expect(normalizeFlow(input)).toBe(expected);
});
it('should return null for anything it does not recognise, never throw', () => {
[undefined, null, '', ' ', 'banana', 42, {}].forEach((value) => {
expect(normalizeFlow(value)).toBeNull();
});
});
});
describe('flow predicates', () => {
it('should identify which flows require travelling out to collect', () => {
expect(hasCustomerPickup(FLOW.REVERSE)).toBe(true);
expect(hasCustomerPickup(FLOW.P2P)).toBe(true);
expect(hasCustomerPickup(FLOW.FORWARD)).toBe(false);
expect(hasCustomerPickup(FLOW.LINEHAUL)).toBe(false);
});
it('should identify which flows end at a facility', () => {
expect(hasHubDrop(FLOW.REVERSE)).toBe(true);
expect(hasHubDrop(FLOW.LINEHAUL)).toBe(true);
expect(hasHubDrop(FLOW.FORWARD)).toBe(false);
expect(hasHubDrop(FLOW.P2P)).toBe(false);
});
it('should label every flow, and fall back rather than render blank', () => {
Object.values(FLOW).forEach((f) => expect(flowLabel(f)).toBeTruthy());
expect(flowLabel(FLOW.REVERSE)).toBe('Customer Pickup');
expect(flowLabel('nonsense')).toBe(flowLabel(FLOW.FORWARD));
});
});
describe('flowForDraft — what the create form books', () => {
it('should book a hub pickup to a typed address as Forward', () => {
const r = flowForDraft({
pickupSource: PICKUP_SOURCE.HUB,
dropLat: DOOR_A.lat,
dropLng: DOOR_A.lng,
anchors
});
expect(r.flow).toBe(FLOW.FORWARD);
});
it('should recognise a drop that lands on a hub as a return, not a delivery', () => {
// Without measuring the drop end, a rider bringing a parcel back to the
// hub would be booked P2P and routed as if the hub were a house.
const r = flowForDraft({
pickupSource: PICKUP_SOURCE.CUSTOMER,
dropLat: HUB.latitude,
dropLng: HUB.longitude,
anchors
});
expect(r.flow).toBe(FLOW.REVERSE);
expect(r.destAnchor?.hubid).toBe(7);
});
it('should book a customer pickup to a customer address as P2P', () => {
const r = flowForDraft({
pickupSource: PICKUP_SOURCE.CUSTOMER,
dropLat: DOOR_B.lat,
dropLng: DOOR_B.lng,
anchors
});
expect(r.flow).toBe(FLOW.P2P);
});
it('should default to a hub pickup when the form has told it nothing', () => {
expect(flowForDraft().flow).toBe(FLOW.FORWARD);
});
});
describe('buildFlowFields — what goes on the wire', () => {
it('should always name the flow', () => {
expect(buildFlowFields({ flow: FLOW.REVERSE }).ordertype).toBe(FLOW.REVERSE);
});
it('should write BOTH hub id fields, which the console has only ever read', () => {
// queries.js has always read b.hubid / b.sourcehubid and the console has
// never written either, which is why zone attribution had to fall back to
// matching hub names against concatenated address text.
const fields = buildFlowFields({ flow: FLOW.FORWARD, servicingHubId: 7, tenantLocationId: 31 });
expect(fields.sourcehubid).toBe(7);
expect(fields.hubid).toBe(7);
expect(fields.tenantlocationid).toBe(31);
});
it('should omit ids it was not given rather than send null', () => {
// pickup_source_type is NOT an id and is always present — see the next
// block for why an omission would say the wrong thing.
expect(buildFlowFields({ flow: FLOW.P2P })).toEqual({
ordertype: FLOW.P2P,
pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER
});
expect(buildFlowFields({ flow: FLOW.P2P, servicingHubId: '' })).toEqual({
ordertype: FLOW.P2P,
pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER
});
});
it('should fall back to Forward rather than put an invented type on the wire', () => {
expect(buildFlowFields({ flow: 'banana' }).ordertype).toBe(FLOW.FORWARD);
expect(buildFlowFields().ordertype).toBe(FLOW.FORWARD);
});
});
describe('pickup_source_type — what kind of place the parcel leaves', () => {
const baseAnchor = anchors.find((a) => a.kind === 'hub');
const siteAnchor = anchors.find((a) => a.kind === 'tenantlocation');
it('should name a base pickup and carry the base id', () => {
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: baseAnchor });
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB);
expect(fields.pickuphubid).toBe(HUB.hubid);
});
it("should call a client's own site a merchant, not a base", () => {
// Both are facilities to the geometry, but a rider arriving at a kitchen
// is not arriving at a base — different signage, different person to ask
// for. Collapsing them is what made every logistics pickup read as the
// rider's own base.
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: siteAnchor });
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.MERCHANT);
expect(fields.pickuphubid).toBeUndefined();
});
it('should send "customer" as a value rather than leaving it off', () => {
// An omission reads as "nobody filled this in", which is a different fact
// from "this is somebody's front door".
expect(buildFlowFields({ flow: FLOW.P2P, pickupAnchor: null }).pickup_source_type).toBe(
PICKUP_SOURCE_TYPE.CUSTOMER
);
});
it('should never put an unknown source type on the wire', () => {
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupSourceType: 'banana', pickupAnchor: baseAnchor });
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB);
});
it('should not name a base on a pickup that is not from one', () => {
const fields = buildFlowFields({
flow: FLOW.REVERSE,
pickupSourceType: PICKUP_SOURCE_TYPE.CUSTOMER,
pickupAnchor: baseAnchor
});
expect(fields.pickuphubid).toBeUndefined();
});
it('should prefer the type stored on the booking over geometry', () => {
// A booking collected at a base whose coordinates were later edited must
// keep reading as a base pickup — the row recorded what actually happened.
const c = classifyBooking(
{
bookingid: 1,
pickup_source_type: PICKUP_SOURCE_TYPE.HUB,
pickuphubid: HUB.hubid,
pickuplatitude: DOOR_A.lat,
pickuplongitude: DOOR_A.lng,
deliverylatitude: DOOR_B.lat,
deliverylongitude: DOOR_B.lng
},
anchors
);
expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB);
expect(c.pickupHubId).toBe(HUB.hubid);
expect(c.sourceTypeFromServer).toBe(true);
});
it('should classify an older booking from geometry when the row says nothing', () => {
const c = classifyBooking(
{
bookingid: 2,
pickuplatitude: HUB.latitude,
pickuplongitude: HUB.longitude,
deliverylatitude: DOOR_B.lat,
deliverylongitude: DOOR_B.lng
},
anchors
);
expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB);
expect(c.sourceTypeFromServer).toBe(false);
});
});
});