updates on bugs on the dispatch and ai button integrations
This commit is contained in:
@@ -71,11 +71,77 @@ const withNoJoins = () => {
|
||||
endpoints.getMilers.mockResolvedValue([]);
|
||||
endpoints.getAdminTenants.mockResolvedValue([]);
|
||||
endpoints.getConsignments.mockResolvedValue([]);
|
||||
// The hub list backs the city filter. Empty here on purpose: `key()` passes
|
||||
// applocationid 1, and with no hubs to resolve it against the filter has to
|
||||
// fail open, leaving every assertion below about status/date/joins alone.
|
||||
endpoints.getHubs.mockResolvedValue([]);
|
||||
};
|
||||
|
||||
describe('deliveries data layer', () => {
|
||||
beforeEach(withNoJoins);
|
||||
|
||||
/**
|
||||
* The hub picker on Control X.
|
||||
*
|
||||
* `applocationid` is slot 1 of the query key, and it was destructured out and
|
||||
* then never read — the same silent no-op this folder's CLAUDE.md records for
|
||||
* startdate/enddate, which it survived. Picking a hub rebuilt the key,
|
||||
* refetched, and returned the identical nationwide row set, so the header
|
||||
* chip read "Coimbatore Neptune Hub" while the map drew a Bangalore rider on
|
||||
* Mysore Road.
|
||||
*
|
||||
* These assert the KEY POSITION is consumed, not just that the helper works —
|
||||
* locationScope.test.js covers the matching rules. A filter nothing calls is
|
||||
* exactly what went wrong here twice.
|
||||
*/
|
||||
describe('fetchDeliveries — the city in the query key', () => {
|
||||
const HUBS = [
|
||||
{ hubid: 1, hubname: 'Coimbatore Jupiter Hub', applocationid: 1, latitude: 11.0168, longitude: 76.9558 },
|
||||
{ hubid: 5, hubname: 'Bangalore Earth Hub', applocationid: 3, latitude: 12.9352, longitude: 77.6244 },
|
||||
];
|
||||
const cityKey = (appId) => ['dispatchDeliveries', appId, null, 'all', undefined, undefined, 100];
|
||||
|
||||
const coimbatore = booking({ bookingid: 1, bookingno: 'DM-CBE',
|
||||
pickuplatitude: 11.0168, pickuplongitude: 76.9558,
|
||||
deliverylatitude: 11.0183, deliverylongitude: 76.9680 });
|
||||
const bangalore = booking({ bookingid: 2, bookingno: 'DM-BLR',
|
||||
pickuplatitude: 12.9352, pickuplongitude: 77.6244,
|
||||
deliverylatitude: 12.9439, deliverylongitude: 77.5092 });
|
||||
|
||||
beforeEach(() => {
|
||||
endpoints.getHubs.mockResolvedValue(HUBS);
|
||||
endpoints.getBookings.mockResolvedValue([coimbatore, bangalore]);
|
||||
});
|
||||
|
||||
it('Coimbatore returns the Coimbatore run and not the Bangalore one', async () => {
|
||||
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(1) });
|
||||
expect(rows.map((r) => r.bookingno)).toEqual(['DM-CBE']);
|
||||
});
|
||||
|
||||
it('Bangalore returns the Bangalore run and not the Coimbatore one', async () => {
|
||||
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(3) });
|
||||
expect(rows.map((r) => r.bookingno)).toEqual(['DM-BLR']);
|
||||
});
|
||||
|
||||
it('All locations returns both', async () => {
|
||||
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(0) });
|
||||
expect(rows.map((r) => r.bookingno).sort()).toEqual(['DM-BLR', 'DM-CBE']);
|
||||
});
|
||||
|
||||
it('a failed hub list shows everything rather than an empty board', async () => {
|
||||
// A blank dispatch board reads as "no work today" and is acted on. It
|
||||
// must never be what a failed side-call produces.
|
||||
endpoints.getHubs.mockRejectedValue(new Error('hubs down'));
|
||||
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(1) });
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('an unknown city shows everything rather than an empty board', async () => {
|
||||
const { rows } = await fetchDeliveries({ pageParam: 1, queryKey: cityKey(999) });
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDeliveries — dispatched filtering', () => {
|
||||
it('should surface a booking that already has a rider assigned', async () => {
|
||||
endpoints.getBookings.mockResolvedValue([booking()]);
|
||||
@@ -623,3 +689,29 @@ describe('deliveries data layer', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDeliveries — the hub list is only fetched when it is needed', () => {
|
||||
// Dispatch polls every 8 seconds and auto-pages through the whole day, so an
|
||||
// unconditional call here is a request per page per tick on a list that four
|
||||
// pages read and three never scope.
|
||||
beforeEach(() => {
|
||||
endpoints.getBookings.mockResolvedValue([]);
|
||||
endpoints.getHubs.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('is not fetched for All locations', async () => {
|
||||
await fetchDeliveries({ pageParam: 1, queryKey: ['d', 0, null, 'all', undefined, undefined, 100] });
|
||||
expect(endpoints.getHubs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is not fetched by a caller that passes no city at all', async () => {
|
||||
// reports/OperationalReportCards passes '' in this slot.
|
||||
await fetchDeliveries({ pageParam: 1, queryKey: ['d', '', '', '', '2026-09-01', '2026-09-30', 500] });
|
||||
expect(endpoints.getHubs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is fetched once a city is selected', async () => {
|
||||
await fetchDeliveries({ pageParam: 1, queryKey: ['d', 3, null, 'all', undefined, undefined, 100] });
|
||||
expect(endpoints.getHubs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
87
tests/ds/aiLauncher.test.jsx
Normal file
87
tests/ds/aiLauncher.test.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
jest.mock('@/assets/images/doormile-mark.png', () => 'doormile-mark.png', { virtual: true });
|
||||
jest.mock('lucide-react', () =>
|
||||
new Proxy({}, {
|
||||
get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />),
|
||||
})
|
||||
);
|
||||
|
||||
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
|
||||
|
||||
/**
|
||||
* The floating control that opens MileTruth, in place of the bare 32px brand
|
||||
* mark that used to sit in the header between search and the bell.
|
||||
*
|
||||
* Two things here are requirements rather than styling, and both are the kind
|
||||
* that a refactor silently undoes: the outline is the brand crimson on hover
|
||||
* and on keyboard focus, and the button steps aside while the panel is open
|
||||
* instead of sitting on top of a docked panel.
|
||||
*/
|
||||
const setup = (props = {}) => {
|
||||
const onToggle = jest.fn();
|
||||
const utils = render(<AILauncher isOpen={false} onToggle={onToggle} {...props} />);
|
||||
return { ...utils, onToggle };
|
||||
};
|
||||
|
||||
describe('the MileTruth launcher', () => {
|
||||
it('is named, not just a mark', () => {
|
||||
// The header version was an unlabelled logo among utility icons, with
|
||||
// nothing on screen saying what it opened.
|
||||
setup();
|
||||
expect(screen.getByText('Doormile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the assistant when clicked', () => {
|
||||
const { onToggle } = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: /open miletruth/i }));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('outlines in brand red on hover, not the default focus blue', () => {
|
||||
// `ring-brand` is #C8102E. A `/20` wash — what the header icon used — reads
|
||||
// as a pale smudge on a white pill rather than as an outline.
|
||||
const { container } = setup();
|
||||
const cls = container.querySelector('button').className;
|
||||
expect(cls).toMatch(/hover:ring-brand(?!\/)/);
|
||||
expect(cls).toMatch(/hover:ring-2/);
|
||||
});
|
||||
|
||||
it('outlines the same way for keyboard focus', () => {
|
||||
const { container } = setup();
|
||||
const cls = container.querySelector('button').className;
|
||||
expect(cls).toMatch(/focus-visible:ring-brand(?!\/)/);
|
||||
// and never falls back to the user agent's own outline, which is the blue
|
||||
// ring this replaces.
|
||||
expect(cls).toMatch(/focus-visible:outline-none/);
|
||||
});
|
||||
|
||||
it('floats bottom-right, below the panel layer', () => {
|
||||
// The panel is z-index 1200 and its scrim 1300. A launcher above either
|
||||
// would sit on top of an open panel.
|
||||
const { container } = setup();
|
||||
const cls = container.querySelector('button').className;
|
||||
expect(cls).toMatch(/fixed/);
|
||||
expect(cls).toMatch(/bottom-5/);
|
||||
expect(cls).toMatch(/right-5/);
|
||||
expect(cls).toMatch(/z-\[1100\]/);
|
||||
});
|
||||
|
||||
it('gets out of the way while the panel is open', () => {
|
||||
// The panel docks to the right on a wide screen and covers the page behind
|
||||
// a scrim on a narrow one. Either way this button would be underneath it.
|
||||
const { container } = render(<AILauncher isOpen onToggle={jest.fn()} />);
|
||||
expect(container.querySelector('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('the mark can never outgrow its slot', () => {
|
||||
// The header icon hit this twice: a 36px image inside a 32px button,
|
||||
// overflowing on every side and breaking the row's alignment.
|
||||
const { container } = setup();
|
||||
const img = container.querySelector('img');
|
||||
expect(img.className).toMatch(/object-contain/);
|
||||
expect(img.className).not.toMatch(/max-w-none/);
|
||||
expect(img).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
70
tests/ds/dispatchHeaderClipping.test.js
Normal file
70
tests/ds/dispatchHeaderClipping.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* The Control X header must not clip its own dropdowns.
|
||||
*
|
||||
* `.logo` holds the page title, the hub picker and the batch picker. Both
|
||||
* pickers render their menu as an absolutely-positioned child of a
|
||||
* `position: relative` wrapper that sits INSIDE `.logo`, so `.logo` is what
|
||||
* decides whether the menu is drawn at all. `#hdr` is 56px tall and the menus
|
||||
* open below their trigger, so with `overflow: hidden` on `.logo` every pixel
|
||||
* of them fell outside the box: the trigger highlighted, the caret rotated, and
|
||||
* nothing appeared. The batch menu's `z-index: 9999` could not save it — z-index
|
||||
* does not escape a clipping ancestor.
|
||||
*
|
||||
* Asserted against the stylesheet because this cannot be caught in jsdom, which
|
||||
* does not lay out or clip anything, and the page it belongs to is a
|
||||
* ~5,800-line Leaflet component that no unit test mounts.
|
||||
*/
|
||||
const css = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/pages/doormile/dispatch/Dispatch.css'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/** The declarations inside one selector's first rule block. */
|
||||
const ruleFor = (selector) => {
|
||||
const at = css.indexOf(selector + ' {');
|
||||
if (at === -1) throw new Error(`no rule for ${selector}`);
|
||||
return css.slice(at, css.indexOf('}', at));
|
||||
};
|
||||
|
||||
const overflowIn = (block) => {
|
||||
const m = [...block.matchAll(/(?:^|\n)\s*overflow\s*:\s*([^;]+);/g)];
|
||||
return m.length ? m[m.length - 1][1].trim() : null;
|
||||
};
|
||||
|
||||
describe('the Control X header', () => {
|
||||
it('does not clip the group that contains the dropdowns', () => {
|
||||
expect(overflowIn(ruleFor('.dispatch-container .logo'))).not.toBe('hidden');
|
||||
});
|
||||
|
||||
it('does not clip the tab group, which holds the batch menu', () => {
|
||||
expect(overflowIn(ruleFor('.dispatch-container #dispatch-top-tabs'))).not.toBe('hidden');
|
||||
});
|
||||
|
||||
it('still keeps the group shrinkable, which is what min-width does', () => {
|
||||
// Removing the overflow must not bring back the overflowing header row
|
||||
// that pushed the clock past the right gutter. `min-width: 0` is the rule
|
||||
// that actually lets a flex item shrink below its content width.
|
||||
expect(ruleFor('.dispatch-container .logo')).toMatch(/min-width:\s*0/);
|
||||
});
|
||||
|
||||
it('clips the title instead, so a narrow header still cannot spill', () => {
|
||||
const title = ruleFor('.dispatch-container .logo-name');
|
||||
expect(title).toMatch(/overflow:\s*hidden/);
|
||||
expect(title).toMatch(/text-overflow:\s*ellipsis/);
|
||||
});
|
||||
|
||||
it('leaves the badge clipping its own transparent padding', () => {
|
||||
// .logo-badge scales its image 150% to cancel the PNG's padding and relies
|
||||
// on its OWN overflow to crop it. That is unrelated to the dropdowns.
|
||||
expect(ruleFor('.dispatch-container .logo-badge')).toMatch(/overflow:\s*hidden/);
|
||||
});
|
||||
|
||||
it('the menu is still positioned under its trigger', () => {
|
||||
const menu = ruleFor('.dispatch-container .logo-city-menu');
|
||||
expect(menu).toMatch(/position:\s*absolute/);
|
||||
expect(menu).toMatch(/top:\s*calc\(100% \+ 6px\)/);
|
||||
});
|
||||
});
|
||||
106
tests/integration/bookingsDatePicker.test.jsx
Normal file
106
tests/integration/bookingsDatePicker.test.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
jest.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onClick }) => <button type="button" onClick={onClick}>{children}</button>,
|
||||
DropdownMenuLabel: ({ children }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () =>
|
||||
new Proxy({}, {
|
||||
get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />),
|
||||
})
|
||||
);
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const lastWeek = dayjs().subtract(7, 'day').format('YYYY-MM-DD');
|
||||
|
||||
// Two customer-app bookings: one placed today, one a week ago.
|
||||
jest.mock('@/lib/assistant/scan', () => ({
|
||||
__esModule: true,
|
||||
drainBookings: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/lib/doormileHooks', () => ({
|
||||
__esModule: true,
|
||||
useBooking: () => ({ data: null, isLoading: false, refetch: jest.fn() }),
|
||||
useAssignMilerToBooking: () => ({ mutateAsync: jest.fn(), isPending: false }),
|
||||
useRiderOptions: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
import Bookings from '@/pages/doormile/bookings/Bookings';
|
||||
import { drainBookings } from '@/lib/assistant/scan';
|
||||
|
||||
const booking = (bookingno, createdat) => ({
|
||||
bookingid: bookingno, bookingno, createdat,
|
||||
status: 'Pending_Pickup', bookingsource: 'Customer_App',
|
||||
pickupaddress: 'Somewhere', pickuppincode: '641001',
|
||||
deliveryaddress: 'Elsewhere', deliverypincode: '641002',
|
||||
});
|
||||
|
||||
const renderPage = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Bookings />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The Bookings list had no date control at all: it drained every page of
|
||||
* `/admin/bookings` and showed the lot, so the newest booking sat wherever the
|
||||
* scan happened to put it and there was no way to ask "what came in today".
|
||||
*
|
||||
* The picker defaults to today, and every number on the page is scoped to it —
|
||||
* which is the part worth pinning. A KPI that keeps counting the whole account
|
||||
* while the table shows one day reads as a broken page, not as a filter.
|
||||
*/
|
||||
describe('the Bookings date picker', () => {
|
||||
beforeEach(() => {
|
||||
drainBookings.mockResolvedValue({
|
||||
rows: [booking('DM-TODAY', `${today} 09:00:00`), booking('DM-OLD', `${lastWeek} 09:00:00`)],
|
||||
truncated: false, scanned: 2, pagesFetched: 1, total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a date control, defaulted to today', async () => {
|
||||
const { container } = renderPage();
|
||||
await waitFor(() => expect(drainBookings).toHaveBeenCalled());
|
||||
// The trigger carries the calendar icon and labels itself with the active
|
||||
// range. Asserted on the container's text rather than a single node because
|
||||
// the label and its "Today" badge are two sibling text nodes.
|
||||
expect(container.querySelector('[data-testid="icon-Calendar"]')).toBeTruthy();
|
||||
expect(container.textContent).toMatch(/Today/);
|
||||
});
|
||||
|
||||
it("shows today's booking and hides the one from last week", async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByText('DM-TODAY')).toBeInTheDocument();
|
||||
expect(screen.queryByText('DM-OLD')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the KPI counts the same day the table shows', async () => {
|
||||
// Was the whole account before the filter existed. A "Customer bookings 2"
|
||||
// over a one-row table is the failure this pins.
|
||||
renderPage();
|
||||
await screen.findByText('DM-TODAY');
|
||||
expect(screen.getByText(/Customer bookings · today/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says a date is filtering when the day is empty, not just "no bookings"', async () => {
|
||||
drainBookings.mockResolvedValue({
|
||||
rows: [booking('DM-OLD', `${lastWeek} 09:00:00`)],
|
||||
truncated: false, scanned: 1, pagesFetched: 1, total: 1,
|
||||
});
|
||||
renderPage();
|
||||
// "Nothing was booked" and "a filter is hiding it" are different facts and
|
||||
// an operator has to be able to tell which one they are looking at.
|
||||
expect(await screen.findByText(/date picker to widen the range/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -224,7 +224,7 @@ describe('ZoneContext', () => {
|
||||
return setup();
|
||||
};
|
||||
|
||||
it.each(['hubid', 'sourcehubid', 'applocationid', 'tenantlocationid', 'locationid'])(
|
||||
it.each(['hubid', 'sourcehubid', 'servicinghubid', 'pickuphubid', 'nearesthubid'])(
|
||||
'should match on a direct %s',
|
||||
(field) => {
|
||||
const { result } = selectKoramangala();
|
||||
@@ -232,6 +232,30 @@ describe('ZoneContext', () => {
|
||||
}
|
||||
);
|
||||
|
||||
// CHANGED. These three used to be asserted as matches, and that was the
|
||||
// bug rather than the contract.
|
||||
//
|
||||
// A staff zone is a HUB. `applocationid` numbers cities, `tenantlocationid`
|
||||
// and `locationid` number a client's own sites — so matching them against a
|
||||
// hub id is a category error that lands orders in cities they never touched.
|
||||
// A delivery row carries several at once (queries.js sets servicinghubid,
|
||||
// applocationid and tenantlocationid together): DM-626241 is servicinghubid
|
||||
// 5, Bangalore Earth Hub, alongside tenantlocationid 3, which read as a hub
|
||||
// id is Hyderabad Mars Hub.
|
||||
//
|
||||
// The old `??` chain hid it by stopping at the first non-null field, so the
|
||||
// collision only fired when the earlier fields happened to be missing.
|
||||
//
|
||||
// A tenant session is the mirror image and keeps its own assertions below:
|
||||
// there the zone IS a tenantlocationid, and hub ids are the wrong space.
|
||||
it.each(['applocationid', 'tenantlocationid', 'locationid'])(
|
||||
'should NOT match a staff hub zone on a %s — different id space',
|
||||
(field) => {
|
||||
const { result } = selectKoramangala();
|
||||
expect(result.current.matchesZone({ bookingid: 1, [field]: '2' })).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('should match a numeric hub id against the string zone id', () => {
|
||||
const { result } = selectKoramangala();
|
||||
expect(result.current.matchesZone({ hubid: 2 })).toBe(true);
|
||||
|
||||
64
tests/lib/filterByCreatedAt.test.js
Normal file
64
tests/lib/filterByCreatedAt.test.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { filterByCreatedAt } from '@/lib/dateRange';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
|
||||
/**
|
||||
* The date filter the Orders and Bookings lists share.
|
||||
*
|
||||
* Both read the same rows from the same endpoint through the same picker, so
|
||||
* the comparison has to be one piece of code. The shapes below are the three
|
||||
* the picker actually emits — a plain day, a `{from, to}` preset, and the
|
||||
* string 'all' — and getting any of them wrong empties the table while looking
|
||||
* like a legitimate "no bookings today".
|
||||
*/
|
||||
const row = (createdat, bookingno) => ({ bookingno, createdat });
|
||||
|
||||
const ROWS = [
|
||||
row('2026-09-22 15:48:00', 'DM-A'),
|
||||
row('2026-09-23 10:10:00', 'DM-B'),
|
||||
row('2026-09-24 10:58:00', 'DM-C'),
|
||||
];
|
||||
const nos = (rows) => rows.map((r) => r.bookingno);
|
||||
|
||||
describe('filterByCreatedAt', () => {
|
||||
it('a single day keeps only that day', () => {
|
||||
expect(nos(filterByCreatedAt(ROWS, '2026-09-23', parseDoormileTimestamp))).toEqual(['DM-B']);
|
||||
});
|
||||
|
||||
it('a {from,to} preset keeps the inclusive span', () => {
|
||||
const got = filterByCreatedAt(ROWS, { from: '2026-09-22', to: '2026-09-23' }, parseDoormileTimestamp);
|
||||
expect(nos(got)).toEqual(['DM-A', 'DM-B']);
|
||||
});
|
||||
|
||||
it("'all' keeps everything", () => {
|
||||
expect(nos(filterByCreatedAt(ROWS, 'all', parseDoormileTimestamp))).toEqual(['DM-A', 'DM-B', 'DM-C']);
|
||||
});
|
||||
|
||||
it('an open-ended from keeps that day onwards', () => {
|
||||
const got = filterByCreatedAt(ROWS, { from: '2026-09-23', to: '' }, parseDoormileTimestamp);
|
||||
expect(nos(got)).toEqual(['DM-B', 'DM-C']);
|
||||
});
|
||||
|
||||
it('a row whose timestamp will not parse is dropped from a dated view', () => {
|
||||
// and NOT from an undated one — the row exists, and "every booking" has to
|
||||
// include it even when its timestamp is unusable.
|
||||
const withJunk = [...ROWS, row('not a date', 'DM-JUNK')];
|
||||
expect(nos(filterByCreatedAt(withJunk, '2026-09-24', parseDoormileTimestamp))).toEqual(['DM-C']);
|
||||
expect(nos(filterByCreatedAt(withJunk, 'all', parseDoormileTimestamp))).toContain('DM-JUNK');
|
||||
});
|
||||
|
||||
it('an empty or missing value is not a filter', () => {
|
||||
expect(filterByCreatedAt(ROWS, '', parseDoormileTimestamp)).toHaveLength(3);
|
||||
expect(filterByCreatedAt(ROWS, undefined, parseDoormileTimestamp)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('survives a null row list', () => {
|
||||
expect(filterByCreatedAt(null, '2026-09-24', parseDoormileTimestamp)).toEqual([]);
|
||||
});
|
||||
|
||||
it("today's default shows today's bookings and nothing else", () => {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const rows = [row(`${today} 09:00:00`, 'TODAY'), row('2020-01-01 09:00:00', 'OLD')];
|
||||
expect(nos(filterByCreatedAt(rows, today, parseDoormileTimestamp))).toEqual(['TODAY']);
|
||||
});
|
||||
});
|
||||
134
tests/lib/locationScope.test.js
Normal file
134
tests/lib/locationScope.test.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import { makeLocationFilter, ZONE_RADIUS_KM } from '@/lib/locationScope';
|
||||
|
||||
/**
|
||||
* Control X's hub picker.
|
||||
*
|
||||
* Its `applocationid` was destructured out of the fetchDeliveries query key and
|
||||
* then ignored — the same silent no-op the folder's CLAUDE.md records for
|
||||
* startdate/enddate, and it survived the fix. Picking a hub rebuilt the key,
|
||||
* refetched, and returned the same nationwide rows, so the header read
|
||||
* "Coimbatore Neptune Hub" over a map drawing a Bangalore rider on Mysore Road.
|
||||
*
|
||||
* The hubs below are the real rows, including `Coimbatore Test Hub` at 0,0 —
|
||||
* the Gulf of Guinea — which must never be used as a centre.
|
||||
*/
|
||||
const HUBS = [
|
||||
{ hubid: 1, hubname: 'Coimbatore Jupiter Hub', applocationid: 1, latitude: 11.0168, longitude: 76.9558, pincode: '641012' },
|
||||
{ hubid: 2, hubname: 'Coimbatore Neptune Hub', applocationid: 1, latitude: 11.0301, longitude: 77.0034, pincode: '641004' },
|
||||
{ hubid: 17, hubname: 'Coimbatore Test Hub', applocationid: 1, latitude: 0, longitude: 0, pincode: '641099' },
|
||||
{ hubid: 5, hubname: 'Bangalore Earth Hub', applocationid: 3, latitude: 12.9352, longitude: 77.6244, pincode: '560034' },
|
||||
{ hubid: 11, hubname: 'Bangalore Whitefield Hub', applocationid: 3, latitude: 12.9698, longitude: 77.7499, pincode: '560066' },
|
||||
];
|
||||
|
||||
const COIMBATORE = 1;
|
||||
const BANGALORE = 3;
|
||||
|
||||
// DM-626241 as fetchDeliveries normalises it: Koramangala → Bangalore University.
|
||||
const bangaloreRun = (over = {}) => ({
|
||||
bookingid: 11, orderid: 'DM-626241', servicinghubid: 5,
|
||||
pickuplatitude: 12.9352, pickuplongitude: 77.6244,
|
||||
deliverylatitude: 12.94393, deliverylongitude: 77.50918,
|
||||
...over,
|
||||
});
|
||||
|
||||
const coimbatoreRun = (over = {}) => ({
|
||||
bookingid: 9, orderid: 'DM-956478', servicinghubid: 1,
|
||||
pickuplatitude: 11.0168, pickuplongitude: 76.9558,
|
||||
deliverylatitude: 11.0183, deliverylongitude: 76.9680,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('the bug this closes', () => {
|
||||
it('a Bangalore run does not appear under Coimbatore', () => {
|
||||
const inCoimbatore = makeLocationFilter(HUBS, COIMBATORE);
|
||||
expect(inCoimbatore(bangaloreRun())).toBe(false);
|
||||
});
|
||||
|
||||
it('and still appears under Bangalore', () => {
|
||||
const inBangalore = makeLocationFilter(HUBS, BANGALORE);
|
||||
expect(inBangalore(bangaloreRun())).toBe(true);
|
||||
});
|
||||
|
||||
it('a Coimbatore run appears under Coimbatore and not Bangalore', () => {
|
||||
expect(makeLocationFilter(HUBS, COIMBATORE)(coimbatoreRun())).toBe(true);
|
||||
expect(makeLocationFilter(HUBS, BANGALORE)(coimbatoreRun())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('"All locations" is not a filter', () => {
|
||||
it.each([0, '0', '', null, undefined, 'nonsense'])('%p returns null', (id) => {
|
||||
expect(makeLocationFilter(HUBS, id)).toBeNull();
|
||||
});
|
||||
|
||||
it('a city with no hubs fails open rather than emptying the board', () => {
|
||||
// A blank board reads as "no work today". That is the more expensive
|
||||
// mistake than showing too much, so an unresolvable picker shows all.
|
||||
expect(makeLocationFilter(HUBS, 99)).toBeNull();
|
||||
expect(makeLocationFilter([], COIMBATORE)).toBeNull();
|
||||
expect(makeLocationFilter(null, COIMBATORE)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('how a row is placed', () => {
|
||||
it('by the hub it names, with no coordinates at all', () => {
|
||||
const inBangalore = makeLocationFilter(HUBS, BANGALORE);
|
||||
expect(inBangalore({ orderid: 'X', pickuphubid: 11 })).toBe(true);
|
||||
expect(makeLocationFilter(HUBS, COIMBATORE)({ orderid: 'X', pickuphubid: 11 })).toBe(false);
|
||||
});
|
||||
|
||||
it('by either end of the journey, not just the pickup', () => {
|
||||
// A run that collects in Coimbatore and delivers there is Coimbatore's
|
||||
// work whichever end you look at; one that only arrives is too.
|
||||
const inCoimbatore = makeLocationFilter(HUBS, COIMBATORE);
|
||||
const inbound = { orderid: 'IN', pickuplatitude: 12.9352, pickuplongitude: 77.6244,
|
||||
deliverylatitude: 11.0168, deliverylongitude: 76.9558 };
|
||||
expect(inCoimbatore(inbound)).toBe(true);
|
||||
});
|
||||
|
||||
it('a stop just outside the radius does not belong', () => {
|
||||
const inBangalore = makeLocationFilter(HUBS, BANGALORE);
|
||||
// ~0.01 degree of latitude is ~1.1km, so 0.5 degrees is ~55km north.
|
||||
const farAway = { orderid: 'FAR', pickuplatitude: 13.45, pickuplongitude: 77.6244,
|
||||
deliverylatitude: 13.46, deliverylongitude: 77.6244 };
|
||||
expect(inBangalore(farAway)).toBe(false);
|
||||
});
|
||||
|
||||
it('the radius is the one ZoneContext uses', () => {
|
||||
expect(ZONE_RADIUS_KM).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hub at 0,0 is not a centre', () => {
|
||||
it('Coimbatore Test Hub does not pull the Gulf of Guinea into Coimbatore', () => {
|
||||
// hub 17 is latitude 0, longitude 0 in the live data. Treated as a real
|
||||
// point it would claim everything within 35km of the equator's origin.
|
||||
const inCoimbatore = makeLocationFilter(HUBS, COIMBATORE);
|
||||
expect(inCoimbatore({ orderid: 'NULL_ISLAND', pickuplatitude: 0.1, pickuplongitude: 0.1,
|
||||
deliverylatitude: 0.1, deliverylongitude: 0.1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('but its hub id still places a row, since the id is not a guess', () => {
|
||||
expect(makeLocationFilter(HUBS, COIMBATORE)({ orderid: 'T', servicinghubid: 17 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a row nobody can place stays visible', () => {
|
||||
it('no hub id and no usable coordinates — kept under every city', () => {
|
||||
// Dropping it would remove it from ALL cities at once, with nothing on
|
||||
// screen to say it had been hidden. That is the failure mode this whole
|
||||
// area keeps producing, and it is worse than one stray row.
|
||||
const orphan = { orderid: 'ORPHAN' };
|
||||
expect(makeLocationFilter(HUBS, COIMBATORE)(orphan)).toBe(true);
|
||||
expect(makeLocationFilter(HUBS, BANGALORE)(orphan)).toBe(true);
|
||||
});
|
||||
|
||||
it('a row with 0,0 coordinates counts as unplaceable, not as an address', () => {
|
||||
const unpinned = { orderid: 'ZERO', pickuplatitude: 0, pickuplongitude: 0,
|
||||
deliverylatitude: 0, deliverylongitude: 0 };
|
||||
expect(makeLocationFilter(HUBS, COIMBATORE)(unpinned)).toBe(true);
|
||||
});
|
||||
|
||||
it('a null row is not a row', () => {
|
||||
expect(makeLocationFilter(HUBS, COIMBATORE)(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
180
tests/lib/zoneHubIds.test.jsx
Normal file
180
tests/lib/zoneHubIds.test.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
jest.mock('@/lib/doormileHooks', () => ({
|
||||
__esModule: true,
|
||||
useHubs: jest.fn(),
|
||||
useTenantLocations: jest.fn()
|
||||
}));
|
||||
jest.mock('@/lib/AuthContext', () => ({ __esModule: true, useAuth: jest.fn() }));
|
||||
|
||||
import { ZoneProvider, useZone } from '@/lib/ZoneContext';
|
||||
import { useHubs, useTenantLocations } from '@/lib/doormileHooks';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
/**
|
||||
* Which orders belong to the selected zone.
|
||||
*
|
||||
* These fixtures are the shapes the API really returns, not convenient ones.
|
||||
* `GET /admin/hubs` serves models.Hub, and `GET /admin/bookings` serves
|
||||
* models.PickupBooking — so a hub here carries `applocationid` and `city`, and
|
||||
* a booking carries `pickuphubid` and `nearesthubid` and NOT `hubid`,
|
||||
* `sourcehubid` or `servicinghubid`. The older suite in ZoneContext.test.jsx
|
||||
* uses a hub fixture with a `city` the endpoint did not send, which is how a
|
||||
* green suite sat on top of a city comparison that could never run.
|
||||
*/
|
||||
|
||||
// Hub 5, Bengaluru. `city` is populated by attachHubCities in adminController.
|
||||
const BLR_EARTH = {
|
||||
hubid: 5, hubname: 'Bangalore Earth Hub', hubtype: 'sorting_center',
|
||||
applocationid: 3, city: 'Bangalore', address: '', latitude: 12.9352,
|
||||
longitude: 77.6244, pincode: '560034', status: 'Active'
|
||||
};
|
||||
const CBE_JUPITER = {
|
||||
hubid: 1, hubname: 'Coimbatore Jupiter Hub', hubtype: 'sorting_center',
|
||||
applocationid: 1, city: 'Coimbatore', address: '', latitude: 11.0168,
|
||||
longitude: 76.9558, pincode: '641012', status: 'Active'
|
||||
};
|
||||
|
||||
const wrapper = ({ children }) => <ZoneProvider>{children}</ZoneProvider>;
|
||||
|
||||
const setup = ({ user = { tenantid: null }, hubs = [CBE_JUPITER, BLR_EARTH], locations = [] } = {}) => {
|
||||
useAuth.mockReturnValue({ user });
|
||||
useHubs.mockReturnValue({ data: hubs, isLoading: false, refetch: jest.fn() });
|
||||
useTenantLocations.mockReturnValue({ data: locations, isLoading: false, refetch: jest.fn() });
|
||||
return renderHook(() => useZone(), { wrapper });
|
||||
};
|
||||
|
||||
const inZone = (result, zoneId, item) => {
|
||||
act(() => result.current.setSelectedZoneId(zoneId));
|
||||
return result.current.matchesZone(item);
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe('a booking is matched by the hub it actually records', () => {
|
||||
it('pickuphubid alone puts the order in that zone', () => {
|
||||
// The column exists, is populated by createExpressBooking, and was being
|
||||
// ignored. Nothing else on this row can place it: no coordinates, and an
|
||||
// address that names neither the hub nor its city.
|
||||
const { result } = setup();
|
||||
expect(inZone(result, '5', {
|
||||
bookingid: 1, bookingno: 'DM-BLR001', pickuphubid: 5,
|
||||
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('nearesthubid alone puts the order in that zone', () => {
|
||||
const { result } = setup();
|
||||
expect(inZone(result, '5', {
|
||||
bookingid: 2, bookingno: 'DM-BLR002', nearesthubid: 5,
|
||||
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('and does NOT put it in a different hub\'s zone', () => {
|
||||
const { result } = setup();
|
||||
expect(inZone(result, '1', {
|
||||
bookingid: 1, bookingno: 'DM-BLR001', pickuphubid: 5,
|
||||
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tenant session never matches on a hub id', () => {
|
||||
const KITCHEN = { tenantlocationid: 5, locationname: 'Indiranagar Kitchen', city: 'Bengaluru',
|
||||
latitude: 12.9784, longitude: 77.6408, pincode: '560038' };
|
||||
|
||||
it('a booking whose pickuphubid is 5 does not land in tenant location 5', () => {
|
||||
// Two different id spaces. Matching them would file an order under a
|
||||
// kitchen it has nothing to do with, on a bare numeric collision.
|
||||
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
|
||||
expect(inZone(result, '5', {
|
||||
bookingid: 3, bookingno: 'DM-X', tenantid: '13', pickuphubid: 5,
|
||||
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('but its own tenantlocationid still matches', () => {
|
||||
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
|
||||
expect(inZone(result, '5', {
|
||||
bookingid: 4, bookingno: 'DM-Y', tenantid: '13', tenantlocationid: 5,
|
||||
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the city comparison, which needs a city on the hub', () => {
|
||||
it('matches a Bangalore address against the Bangalore hub', () => {
|
||||
// `hub.city` comes from the applocations join. Before that field existed
|
||||
// this branch read '' and was skipped on every staff session, leaving a
|
||||
// 35km radius as the only thing still matching.
|
||||
const { result } = setup();
|
||||
expect(inZone(result, '5', {
|
||||
bookingid: 5, bookingno: 'DM-BLR003',
|
||||
pickupaddress: '80 Feet Road, Koramangala, Bangalore',
|
||||
deliveryaddress: '100 Feet Road, Indiranagar, Bangalore'
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('a hub with no city does not match everything', () => {
|
||||
// Failing open here would put every order in every zone.
|
||||
const { result } = setup({ hubs: [{ ...BLR_EARTH, city: undefined, latitude: undefined, longitude: undefined }] });
|
||||
expect(inZone(result, '5', {
|
||||
bookingid: 6, bookingno: 'DM-Z',
|
||||
pickupaddress: 'Somewhere else entirely', deliveryaddress: 'Also elsewhere'
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an order with no coordinates is not silently lost', () => {
|
||||
it('still reaches its zone through pickuphubid', () => {
|
||||
// The case that started this: coordinates were the only working matcher,
|
||||
// so an order saved without them was invisible in every zone at once.
|
||||
const { result } = setup();
|
||||
const noCoords = {
|
||||
bookingid: 7, bookingno: 'DM-NOCOORD', pickuphubid: 5,
|
||||
pickuplatitude: 0, pickuplongitude: 0,
|
||||
deliverylatitude: 0, deliverylongitude: 0,
|
||||
pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
|
||||
};
|
||||
expect(inZone(result, '5', noCoords)).toBe(true);
|
||||
expect(inZone(result, '1', noCoords)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one id space at a time', () => {
|
||||
// queries.js puts servicinghubid, applocationid and tenantlocationid on the
|
||||
// SAME delivery row. They are three different numbering schemes, and testing
|
||||
// them all against a hub id files orders under cities they never touched.
|
||||
const DM626241 = {
|
||||
bookingid: 11, bookingno: 'DM-626241',
|
||||
servicinghubid: 5, // Bangalore Earth Hub — the real owner
|
||||
tenantlocationid: 3, // a client site; as a HUB id this is Hyderabad
|
||||
applocationid: 3, // a CITY id; as a hub id this is Hyderabad too
|
||||
pickupaddress: 'Koramangala', deliveryaddress: 'Mysore Road',
|
||||
};
|
||||
const HYDERABAD_MARS = {
|
||||
hubid: 3, hubname: 'Hyderabad Mars Hub', applocationid: 2, city: 'Hyderabad',
|
||||
latitude: 17.4483, longitude: 78.3741, pincode: '500032', status: 'Active',
|
||||
};
|
||||
|
||||
it('a Bangalore run does not land in Hyderabad on a numeric collision', () => {
|
||||
const { result } = setup({ hubs: [CBE_JUPITER, BLR_EARTH, HYDERABAD_MARS] });
|
||||
expect(inZone(result, '3', DM626241)).toBe(false);
|
||||
});
|
||||
|
||||
it('and still lands in Bangalore, by the hub it actually names', () => {
|
||||
const { result } = setup({ hubs: [CBE_JUPITER, BLR_EARTH, HYDERABAD_MARS] });
|
||||
expect(inZone(result, '5', DM626241)).toBe(true);
|
||||
});
|
||||
|
||||
it('a tenant session matches its own locations and no hub id', () => {
|
||||
const KITCHEN = { tenantlocationid: 5, locationname: 'Indiranagar Kitchen',
|
||||
city: 'Bengaluru', latitude: 12.9784, longitude: 77.6408, pincode: '560038' };
|
||||
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] });
|
||||
expect(inZone(result, '5', { bookingid: 1, tenantid: '13', tenantlocationid: 5,
|
||||
pickupaddress: 'a', deliveryaddress: 'b' })).toBe(true);
|
||||
expect(inZone(result, '5', { bookingid: 2, tenantid: '13', servicinghubid: 5,
|
||||
pickupaddress: 'a', deliveryaddress: 'b' })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user