290 lines
13 KiB
JavaScript
290 lines
13 KiB
JavaScript
jest.mock('@/api/doormile', () => ({
|
|
__esModule: true,
|
|
getMilers: jest.fn(),
|
|
buildMilerLookup: jest.fn(),
|
|
notifyRider: jest.fn(),
|
|
batchAssignBookings: jest.fn()
|
|
}));
|
|
|
|
const api = require('@/api/doormile');
|
|
const {
|
|
executeProposal,
|
|
canExecuteProposal,
|
|
executableTools
|
|
} = require('@/lib/assistant/agent/actions');
|
|
|
|
// Rows as findings carry them: mileruserid, never milerprofileid.
|
|
const row = (over = {}) => ({ bookingid: 1, orderid: 'DM-BK-001', userid: 7, ridername: 'Suresh', ...over });
|
|
|
|
const finding = (tool, scope, severity = 'critical') => ({
|
|
id: 'stalled_at_door',
|
|
severity,
|
|
proposal: { label: 'Message waiting riders', tool, scope, blastRadius: 'Sends a check-in.' }
|
|
});
|
|
|
|
// The real bridge's shape: Maps keyed by string id.
|
|
const lookupOf = (milers) => ({
|
|
byUserId: new Map(milers.map((m) => [String(m.userid), m])),
|
|
byProfileId: new Map(milers.map((m) => [String(m.milerprofileid), m])),
|
|
byName: new Map()
|
|
});
|
|
|
|
beforeEach(() => {
|
|
api.getMilers.mockResolvedValue([{ userid: 7, milerprofileid: 99, displayname: 'Suresh' }]);
|
|
api.buildMilerLookup.mockImplementation(lookupOf);
|
|
api.notifyRider.mockResolvedValue({ success: true });
|
|
api.batchAssignBookings.mockResolvedValue({ success: true });
|
|
});
|
|
|
|
describe('which proposals can be executed', () => {
|
|
it('only verbs with a real endpoint behind them', () => {
|
|
// alert_low_battery_rider joined notifyRider: it targets the same
|
|
// POST /admin/milers/:id/notify, so it was never a missing capability —
|
|
// just a verb nothing had been wired to.
|
|
expect(executableTools().sort()).toEqual([
|
|
'alert_low_battery_rider', 'assignMiler', 'notifyRider', 'trigger_auto_dispatch'
|
|
]);
|
|
});
|
|
|
|
it('canExecuteProposal is false for the skills tools with no endpoint', () => {
|
|
// Still review-only. Each needs an endpoint that does not exist yet —
|
|
// seed.go marks all three Target: "none yet".
|
|
['enforce_cash_handoff', 'enforce_otp_verification', 'dispatch_hub_idle_parcels']
|
|
.forEach((tool) => {
|
|
expect(canExecuteProposal(finding(tool, [row()]))).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('alert_low_battery_rider notifies the riders on the finding', async () => {
|
|
const result = await executeProposal(finding('alert_low_battery_rider', [row()]));
|
|
expect(result.ok).toBe(true);
|
|
// Keyed on milerprofileid, not userid — the two are different small
|
|
// integers on the same record and the wrong one fails silently.
|
|
expect(api.notifyRider).toHaveBeenCalledWith(99, expect.any(String), expect.stringMatching(/charge/i));
|
|
});
|
|
|
|
// Resolving quietly would let a caller mistake "nothing happened" for success.
|
|
it('throws rather than silently doing nothing', async () => {
|
|
await expect(executeProposal(finding('enforce_cash_handoff', [row()]))).rejects.toThrow(/review-only/i);
|
|
});
|
|
});
|
|
|
|
describe('notifyRider', () => {
|
|
// The trap: /admin/milers/:id/notify keys off milerprofileid, findings carry
|
|
// mileruserid. Both are small integers on the same record, and using the
|
|
// wrong one pages a different rider or 404s — silently, either way.
|
|
it('translates mileruserid to milerprofileid before pushing', async () => {
|
|
await executeProposal(finding('notifyRider', [row({ userid: 7 })]));
|
|
expect(api.notifyRider).toHaveBeenCalledTimes(1);
|
|
expect(api.notifyRider.mock.calls[0][0]).toBe(99); // profile id, NOT 7
|
|
});
|
|
|
|
it('fetches the miler list once for the whole batch', async () => {
|
|
await executeProposal(finding('notifyRider', [row({ bookingid: 1 }), row({ bookingid: 2 }), row({ bookingid: 3 })]));
|
|
expect(api.getMilers).toHaveBeenCalledTimes(1);
|
|
expect(api.notifyRider).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
// A rider with no registered device cannot be pushed to. Saying nothing would
|
|
// leave the operator assuming a phone buzzed.
|
|
it('names riders it could not reach instead of skipping them', async () => {
|
|
api.getMilers.mockResolvedValue([{ userid: 7, milerprofileid: undefined, displayname: 'Suresh' }]);
|
|
const res = await executeProposal(finding('notifyRider', [row({ userid: 7 })]));
|
|
|
|
expect(api.notifyRider).not.toHaveBeenCalled();
|
|
expect(res.ok).toBe(false);
|
|
expect(res.unreachable[0]).toMatch(/no device registered/i);
|
|
});
|
|
|
|
it('reports a partial success as partial, naming the failures', async () => {
|
|
api.getMilers.mockResolvedValue([
|
|
{ userid: 7, milerprofileid: 99 },
|
|
{ userid: 8, milerprofileid: 100 }
|
|
]);
|
|
api.notifyRider.mockImplementation((id) => (id === 100 ? Promise.reject(new Error('device offline')) : Promise.resolve()));
|
|
|
|
const res = await executeProposal(
|
|
finding('notifyRider', [row({ bookingid: 1, userid: 7 }), row({ bookingid: 2, orderid: 'DM-BK-002', userid: 8 })])
|
|
);
|
|
|
|
expect(res.ok).toBe(true); // one did land
|
|
expect(res.notified).toEqual(['DM-BK-001']);
|
|
expect(res.message).toMatch(/Notified 1 rider/);
|
|
expect(res.message).toMatch(/Could not reach 1/);
|
|
expect(res.message).toMatch(/DM-BK-002/);
|
|
});
|
|
|
|
it('reports failure when nothing landed at all', async () => {
|
|
api.notifyRider.mockRejectedValue(new Error('network'));
|
|
const res = await executeProposal(finding('notifyRider', [row()]));
|
|
expect(res.ok).toBe(false);
|
|
expect(res.notified).toEqual([]);
|
|
});
|
|
|
|
it('refuses when no row carries a rider', async () => {
|
|
const res = await executeProposal(finding('notifyRider', [row({ userid: undefined })]));
|
|
expect(res.ok).toBe(false);
|
|
expect(res.message).toMatch(/assigned rider to notify/i);
|
|
expect(api.notifyRider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('escalates the wording for critical findings', async () => {
|
|
await executeProposal(finding('notifyRider', [row()], 'critical'));
|
|
expect(api.notifyRider.mock.calls[0][2]).toMatch(/urgent/i);
|
|
|
|
api.notifyRider.mockClear();
|
|
await executeProposal(finding('notifyRider', [row()], 'warning'));
|
|
expect(api.notifyRider.mock.calls[0][2]).not.toMatch(/urgent/i);
|
|
});
|
|
|
|
it('records a source call per push so the operator can verify', async () => {
|
|
const res = await executeProposal(finding('notifyRider', [row()]));
|
|
expect(res.sourceCalls[0].target).toMatch(/\/admin\/milers\/99\/notify/);
|
|
expect(res.sourceCalls[0].status).toBe('complete');
|
|
});
|
|
});
|
|
|
|
describe('assignMiler', () => {
|
|
// This was review-only because POST /hub/bookings/batch-assign is behind
|
|
// HubStaffAuth (roleid 6 only) and 403s for every token this console issues.
|
|
// It is executable now that POST /admin/bookings/batch-assign runs the same
|
|
// solver under admin auth.
|
|
it('is executable', () => {
|
|
expect(canExecuteProposal(finding('assignMiler', [row({ bookingid: 1 })]))).toBe(true);
|
|
});
|
|
|
|
it('sends the findings distinct booking ids, deduplicated', async () => {
|
|
api.batchAssignBookings.mockResolvedValue({ assigned: 2, skipped: 0, riderssequenced: 1, results: [] });
|
|
await executeProposal(finding('assignMiler', [
|
|
row({ bookingid: 11 }), row({ bookingid: 12 }), row({ bookingid: 11 })
|
|
]));
|
|
expect(api.batchAssignBookings).toHaveBeenCalledWith([11, 12]);
|
|
});
|
|
|
|
// The rule the whole module exists for: never report an action that did not
|
|
// happen. Four of nine landing is four of nine.
|
|
it('reports a partial assignment as partial, naming the reasons', async () => {
|
|
api.batchAssignBookings.mockResolvedValue({
|
|
assigned: 1, skipped: 1, riderssequenced: 1,
|
|
results: [
|
|
{ bookingid: 11, bookingno: 'DM-11', assigned: true, mileruserid: 7 },
|
|
{ bookingid: 12, bookingno: 'DM-12', assigned: false, reason: 'no available rider under capacity' }
|
|
]
|
|
});
|
|
const res = await executeProposal(finding('assignMiler', [row({ bookingid: 11 }), row({ bookingid: 12 })]));
|
|
expect(res.ok).toBe(true);
|
|
// Declared so reportActed records "partial" rather than "ok". Caught by
|
|
// driving the real page: the message said partial, the telemetry said ok.
|
|
expect(res.partial).toBe(true);
|
|
expect(res.message).toMatch(/Assigned 1 order/);
|
|
expect(res.message).toMatch(/DM-12 \(no available rider under capacity\)/);
|
|
});
|
|
|
|
// The mocks above return a FLAT object, but batchAssignBookings returns
|
|
// `response.data` — and utils.OK wraps everything as {success, data}. So
|
|
// production hands the executor {data: {assigned, skipped, ...}}. Testing
|
|
// only the flat shape would leave the real one unproven.
|
|
it('reads the real utils.OK envelope, not just a flat object', async () => {
|
|
api.batchAssignBookings.mockResolvedValue({
|
|
success: true,
|
|
data: {
|
|
assigned: 1, skipped: 1, riderssequenced: 1,
|
|
results: [
|
|
{ bookingid: 11, bookingno: 'DM-11', assigned: true, mileruserid: 7 },
|
|
{ bookingid: 12, bookingno: 'DM-12', assigned: false, reason: 'no available rider under capacity' }
|
|
]
|
|
}
|
|
});
|
|
const res = await executeProposal(finding('assignMiler', [row({ bookingid: 11 }), row({ bookingid: 12 })]));
|
|
expect(res.ok).toBe(true);
|
|
expect(res.message).toMatch(/Assigned 1 order/);
|
|
expect(res.message).toMatch(/DM-12/);
|
|
});
|
|
|
|
it('is not ok when nothing was assigned', async () => {
|
|
api.batchAssignBookings.mockResolvedValue({
|
|
assigned: 0, skipped: 1, riderssequenced: 0,
|
|
results: [{ bookingid: 12, bookingno: 'DM-12', assigned: false, reason: 'no available rider under capacity' }]
|
|
});
|
|
const res = await executeProposal(finding('assignMiler', [row({ bookingid: 12 })]));
|
|
expect(res.ok).toBe(false);
|
|
expect(res.sourceCalls[0].status).toBe('error');
|
|
});
|
|
|
|
// Stop sequencing needs ROUTE_OPTIMIZER_URL set in the cluster. Saying
|
|
// nothing when it is zero leaves the operator assuming routes were ordered.
|
|
it('says so when stops were not sequenced', async () => {
|
|
api.batchAssignBookings.mockResolvedValue({ assigned: 2, skipped: 0, riderssequenced: 0, results: [] });
|
|
const res = await executeProposal(finding('assignMiler', [row({ bookingid: 11 }), row({ bookingid: 12 })]));
|
|
expect(res.message).toMatch(/not sequenced/i);
|
|
});
|
|
|
|
it('refuses findings with no booking rather than calling the endpoint', async () => {
|
|
const res = await executeProposal(finding('assignMiler', [row({ bookingid: undefined })]));
|
|
expect(res.ok).toBe(false);
|
|
expect(api.batchAssignBookings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports a thrown error instead of claiming success', async () => {
|
|
api.batchAssignBookings.mockRejectedValue(new Error('403 forbidden'));
|
|
const res = await executeProposal(finding('assignMiler', [row({ bookingid: 11 })]));
|
|
expect(res.ok).toBe(false);
|
|
expect(res.message).toMatch(/403/);
|
|
});
|
|
});
|
|
|
|
// The gap the tests above have: they mock buildMilerLookup, so the id
|
|
// translation is only ever checked against a stand-in. This uses the REAL one.
|
|
//
|
|
// It matters because the whole notify path hinges on it. mileruserid and
|
|
// milerprofileid are both small integers on the same record, so a translation
|
|
// that quietly returned the input would look identical in every mocked test
|
|
// and would page the wrong rider in production.
|
|
describe('notifyRider against the real buildMilerLookup', () => {
|
|
const realLookup = jest.requireActual('@/api/doormile/queries').buildMilerLookup;
|
|
|
|
beforeEach(() => {
|
|
api.buildMilerLookup.mockImplementation(realLookup);
|
|
api.getMilers.mockResolvedValue([
|
|
{ userid: 8, milerprofileid: 501, displayname: 'Kumar', phone: '900' },
|
|
{ userid: 7, milerprofileid: 499, displayname: 'Suresh', phone: '901' }
|
|
]);
|
|
api.notifyRider.mockResolvedValue({ success: true });
|
|
});
|
|
|
|
it('resolves mileruserid 8 to milerprofileid 501, not to 8', async () => {
|
|
await executeProposal(finding('notifyRider', [row({ userid: 8, ridername: 'Kumar' })]));
|
|
|
|
expect(api.notifyRider).toHaveBeenCalledTimes(1);
|
|
const pushedTo = api.notifyRider.mock.calls[0][0];
|
|
expect(pushedTo).toBe(501);
|
|
expect(pushedTo).not.toBe(8); // the failure mode, stated explicitly
|
|
});
|
|
|
|
it('pushes each rider to their own profile id', async () => {
|
|
await executeProposal(
|
|
finding('notifyRider', [
|
|
row({ bookingid: 1, userid: 8, ridername: 'Kumar' }),
|
|
row({ bookingid: 2, orderid: 'DM-BK-002', userid: 7, ridername: 'Suresh' })
|
|
])
|
|
);
|
|
expect(api.notifyRider.mock.calls.map((c) => c[0]).sort()).toEqual([499, 501]);
|
|
});
|
|
|
|
// A rider who has left the fleet is not in the list any more. That must be
|
|
// reported, not crash the whole batch and not silently push to undefined.
|
|
it('reports a rider missing from the fleet list instead of throwing', async () => {
|
|
const res = await executeProposal(
|
|
finding('notifyRider', [
|
|
row({ bookingid: 1, userid: 8, ridername: 'Kumar' }),
|
|
row({ bookingid: 2, orderid: 'DM-BK-002', userid: 404, ridername: 'Gone' })
|
|
])
|
|
);
|
|
|
|
expect(api.notifyRider).toHaveBeenCalledTimes(1);
|
|
expect(api.notifyRider.mock.calls[0][0]).toBe(501);
|
|
expect(res.ok).toBe(true);
|
|
expect(res.unreachable.join(' ')).toMatch(/DM-BK-002/);
|
|
});
|
|
});
|