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

201 lines
8.4 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', () => {
expect(executableTools().sort()).toEqual(['notifyRider']);
});
it('canExecuteProposal is false for the skills tools with no endpoint', () => {
['trigger_auto_dispatch', 'enforce_cash_handoff', 'enforce_otp_verification', 'alert_low_battery_rider']
.forEach((tool) => {
expect(canExecuteProposal(finding(tool, [row()]))).toBe(false);
});
});
// 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', () => {
// Deliberately review-only in the console. The branch executed it through
// POST /hub/bookings/batch-assign, which is behind HubStaffAuth (roleid 6
// only) — every admin/manager/executive click would 403. Until an admin
// batch-assign route exists, the card must say "Review only", not fail.
it('is not executable, so its card renders "Review only"', () => {
expect(canExecuteProposal(finding('assignMiler', [row({ bookingid: 1 })]))).toBe(false);
});
it('throws instead of pretending, and never calls the hub-only endpoint', async () => {
await expect(executeProposal(finding('assignMiler', [row()]))).rejects.toThrow(/review-only/);
expect(api.batchAssignBookings).not.toHaveBeenCalled();
});
});
// 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/);
});
});