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