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

168 lines
6.2 KiB
JavaScript

// Does asking for a status sweep actually reach the agent?
//
// The regex was verified in isolation, which proves nothing about routing:
// answerQuestion walks 26 intents in order and returns the FIRST whose match()
// recognises the text and whose run() resolves. Several later intents match on
// bare substrings ("orders", "riders", "status") that appear in almost every
// phrasing of "what needs attention", so ordering is the whole ballgame — and
// ordering is exactly what a unit test of the regex cannot see.
jest.mock('pages/api/doormileApi', () => ({
__esModule: true,
getBookingsPage: jest.fn(),
getHubs: jest.fn(async () => []),
getVehicles: jest.fn(async () => []),
getTripsheets: jest.fn(async () => []),
getExceptions: jest.fn(async () => []),
getAppUsers: jest.fn(async () => []),
getAdminCustomers: jest.fn(async () => []),
getAdminPricing: jest.fn(async () => []),
getMilers: jest.fn(async () => []),
getConsignments: jest.fn(async () => []),
getPartners: jest.fn(async () => []),
getCompetitorBranches: jest.fn(async () => []),
getCarrierPricing: jest.fn(async () => []),
getMilerSummary: jest.fn(async () => []),
getMilerActivity: jest.fn(async () => []),
trackConsignment: jest.fn(async () => null),
getConsignmentLogs: jest.fn(async () => []),
getAdminTenant: jest.fn(async () => null),
getTenantLocations: jest.fn(async () => [])
}));
jest.mock('pages/api/api', () => ({
__esModule: true,
getalltenants: jest.fn(async () => []),
getallridersummary: jest.fn(async () => [])
}));
let getBookingsPage;
// intents.js keeps a module-local page cache with a 20s TTL, so a second test
// would otherwise be answered from the first test's fixture. Re-requiring the
// module per test gives each one a clean cache.
//
// Time is pinned too: the agent's rules are all relative to `now`, and the
// intent reads the real clock. Without a fixed instant these assertions would
// pass or fail depending on the hour the suite happened to run.
const NOW = new Date('2026-08-29T14:00:00');
let answerQuestion;
// One page of raw bookings holding a breach, a stalled rider and aging work.
const page = () => ({
total: 3,
rows: [
{
bookingid: 1,
bookingno: 'DM-BK-001',
status: 'Pickup_Scheduled',
createdat: '2026-08-29 09:00:00',
assignedmileruserid: 7,
milername: 'Suresh',
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:30:00' }]
},
{
bookingid: 2,
bookingno: 'DM-BK-002',
status: 'Pickup_Scheduled',
createdat: '2026-08-29 10:00:00',
reachedat: '2026-08-29 13:15:00',
assignedmileruserid: 8,
milername: 'Kumar',
serviceoptions: []
},
{ bookingid: 3, bookingno: 'DM-BK-003', status: 'Pending_Pickup', createdat: '2026-08-29 11:30:00', serviceoptions: [] }
]
});
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
jest.setSystemTime(NOW);
jest.resetModules();
// resetModules rebuilds intents.js AND its dependencies, so the mock has to
// be re-required and re-configured here. Configuring the previous instance
// would leave the copy intents.js actually holds returning undefined.
({ getBookingsPage } = require('pages/api/doormileApi'));
getBookingsPage.mockResolvedValue(page());
({ answerQuestion } = require('@/lib/assistant/intents'));
});
afterEach(() => {
jest.useRealTimers();
});
describe('routing to the ops agent', () => {
const sweepPhrasings = [
'what needs attention',
'anything going wrong',
'what needs my attention right now',
'ops check',
'daily briefing',
'what should i do'
];
it.each(sweepPhrasings)('"%s" reaches the agent', async (question) => {
const result = await answerQuestion(question);
expect(result).toBeTruthy();
expect(result.intentId).toBe('opsBriefing');
});
// The other side of the contract. These must keep going to the intents that
// answer them properly — over-claiming here would silently change answers
// across the whole catalog.
const mustNotClaim = [
'how many orders today',
'how many riders are active',
'total revenue today',
'how many tenants',
// Narrowed on port: the branch claimed these, which would have replaced the
// delayed-orders count (Orders taxonomy) and the operations summary chip
// with a briefing. They keep going to the intents that answer them.
'late orders',
'which orders are delayed?',
"give me today's operations summary"
];
it.each(mustNotClaim)('"%s" is NOT swallowed by the agent', async (question) => {
const result = await answerQuestion(question);
expect(result?.intentId).not.toBe('opsBriefing');
});
});
describe('the agent answer itself', () => {
it('reports the real findings from the scanned rows', async () => {
const result = await answerQuestion('what needs attention');
expect(result.headline).toMatch(/need.*attention now/i);
// Assert on the evidence, not on a skill's prose. The wording belongs to
// the skill definition and an ops lead may reasonably reword it; the
// booking reference and the overdue figure are the substance.
expect(result.detail).toMatch(/DM-BK-001/);
expect(result.detail).toMatch(/90 min overdue/);
});
// The verifiability contract: every answer shows which endpoint produced it.
it('carries a source call naming the endpoint it queried', async () => {
const result = await answerQuestion('what needs attention');
expect(Array.isArray(result.sourceCalls)).toBe(true);
expect(result.sourceCalls.length).toBeGreaterThan(0);
expect(result.sourceCalls[0].target).toMatch(/\/admin\/bookings/);
});
it('carries a metric the panel can render', async () => {
const result = await answerQuestion('what needs attention');
expect(result.metric.value).toBeGreaterThan(0);
});
it('says the board is clear when it is', async () => {
getBookingsPage.mockResolvedValue({ total: 1, rows: [
{ bookingid: 9, bookingno: 'DM-BK-009', status: 'Delivered', createdat: '2026-08-29 13:59:00', serviceoptions: [] }
] });
const result = await answerQuestion('what needs attention');
expect(result.intentId).toBe('opsBriefing');
expect(result.headline).toMatch(/Nothing needs attention/i);
});
});