158 lines
6.5 KiB
JavaScript
158 lines
6.5 KiB
JavaScript
/**
|
|
* Finding persistence.
|
|
*
|
|
* The fingerprint is the part worth testing hardest. It decides whether a
|
|
* finding seen on two consecutive polls is the SAME finding — and if it is
|
|
* wrong, the "how long has this been open" measurement, which is the single
|
|
* most useful thing the table records, is destroyed.
|
|
*/
|
|
jest.mock('@/api/doormile', () => ({
|
|
reportFindings: jest.fn().mockResolvedValue({}),
|
|
reportFindingActed: jest.fn().mockResolvedValue({})
|
|
}));
|
|
|
|
const api = require('@/api/doormile');
|
|
const { fingerprintOf, reportScan, reportActed, __resetForTests } =
|
|
require('@/lib/assistant/agent/findingReport');
|
|
|
|
const finding = (skillId, bookingids, extra = {}) => ({
|
|
skillId,
|
|
severity: 'warning',
|
|
title: 'Something needs attention',
|
|
proposal: { tool: 'notifyRider', scope: bookingids.map((bookingid) => ({ bookingid })) },
|
|
...extra
|
|
});
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
__resetForTests();
|
|
// The module reads tenantid from localStorage.
|
|
localStorage.setItem('tenantid', '7');
|
|
});
|
|
|
|
describe('fingerprintOf', () => {
|
|
it('is stable across scans regardless of scope order', () => {
|
|
// Scope order is not guaranteed between scans; an unsorted fingerprint
|
|
// would make the same problem look new every poll.
|
|
expect(fingerprintOf(finding('SlaGuardian', [3, 1, 2])))
|
|
.toBe(fingerprintOf(finding('SlaGuardian', [1, 2, 3])));
|
|
});
|
|
|
|
it('ignores severity, so an escalating finding stays the same finding', () => {
|
|
// An ageing SLA breach climbs warning -> critical while the underlying
|
|
// problem is unchanged. Including severity would reset firstseenat and
|
|
// report a six-hour-old problem as brand new.
|
|
const a = fingerprintOf(finding('SlaGuardian', [1], { severity: 'warning' }));
|
|
const b = fingerprintOf(finding('SlaGuardian', [1], { severity: 'critical' }));
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it('distinguishes two proposals from ONE skill over the same bookings', () => {
|
|
// SlaGuardian raises both "notify the riders" and "assign these orders"
|
|
// for the same parcels. Keyed on skill + scope alone these collide, and
|
|
// since the fingerprint is the upsert key one would silently overwrite the
|
|
// other. Found by running the real page, not by these tests.
|
|
const notify = finding('SlaGuardian', [1, 2, 3]);
|
|
const assign = { ...finding('SlaGuardian', [1, 2, 3]), proposal: { tool: 'assignMiler', scope: [1, 2, 3].map((bookingid) => ({ bookingid })) } };
|
|
expect(fingerprintOf(notify)).not.toBe(fingerprintOf(assign));
|
|
});
|
|
|
|
it('distinguishes different skills on the same booking', () => {
|
|
expect(fingerprintOf(finding('SlaGuardian', [1])))
|
|
.not.toBe(fingerprintOf(finding('CashExposure', [1])));
|
|
});
|
|
|
|
it('distinguishes different scopes', () => {
|
|
expect(fingerprintOf(finding('SlaGuardian', [1])))
|
|
.not.toBe(fingerprintOf(finding('SlaGuardian', [1, 2])));
|
|
});
|
|
|
|
it('survives a missing scope rather than throwing', () => {
|
|
expect(fingerprintOf({ skillId: 'SlaGuardian' })).toBe('SlaGuardian:none:');
|
|
expect(fingerprintOf({})).toBe('unknown:none:');
|
|
});
|
|
});
|
|
|
|
describe('reportScan', () => {
|
|
it('sends ids only, never personal data', async () => {
|
|
await reportScan([finding('SlaGuardian', [11])]);
|
|
const payload = api.reportFindings.mock.calls[0][0];
|
|
const serialised = JSON.stringify(payload);
|
|
expect(payload.findings[0].bookingids).toEqual([11]);
|
|
// The findings table measures the skills; it is not a second copy of the
|
|
// bookings.
|
|
expect(serialised).not.toMatch(/phone|address|recipient|ridername/i);
|
|
});
|
|
|
|
it('reports what cleared since the previous scan', async () => {
|
|
await reportScan([finding('SlaGuardian', [1]), finding('CashExposure', [2])]);
|
|
api.reportFindings.mockClear();
|
|
|
|
await reportScan([finding('SlaGuardian', [1])]);
|
|
const payload = api.reportFindings.mock.calls[0][0];
|
|
expect(payload.cleared).toEqual(['CashExposure:notifyRider:2']);
|
|
});
|
|
|
|
it('skips the round trip when nothing is open and nothing cleared', async () => {
|
|
await reportScan([]);
|
|
expect(api.reportFindings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('still reports when everything clears at once', async () => {
|
|
await reportScan([finding('SlaGuardian', [1])]);
|
|
api.reportFindings.mockClear();
|
|
await reportScan([]);
|
|
expect(api.reportFindings).toHaveBeenCalledWith({
|
|
findings: [], cleared: ['SlaGuardian:notifyRider:1']
|
|
});
|
|
});
|
|
|
|
it('never throws when the backend refuses', async () => {
|
|
api.reportFindings.mockRejectedValueOnce(new Error('403'));
|
|
// A dispatcher looking at a stalled parcel does not care that telemetry is
|
|
// down, and must not be shown an error about it.
|
|
await expect(reportScan([finding('SlaGuardian', [1])])).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('retries the clear set after a failure instead of losing it', async () => {
|
|
await reportScan([finding('SlaGuardian', [1])]);
|
|
api.reportFindings.mockClear();
|
|
|
|
// The scan where it clears fails...
|
|
api.reportFindings.mockRejectedValueOnce(new Error('503'));
|
|
await reportScan([]);
|
|
|
|
// ...so the next scan must still report it cleared. Advancing `previous`
|
|
// on a failed post would drop the clear permanently and leave the finding
|
|
// open forever.
|
|
await reportScan([]);
|
|
const last = api.reportFindings.mock.calls.at(-1)[0];
|
|
expect(last.cleared).toEqual(['SlaGuardian:notifyRider:1']);
|
|
});
|
|
});
|
|
|
|
describe('reportActed', () => {
|
|
it('records a clean success as ok', async () => {
|
|
await reportActed(finding('SlaGuardian', [1]), { ok: true });
|
|
expect(api.reportFindingActed).toHaveBeenCalledWith('SlaGuardian:notifyRider:1', 'ok');
|
|
});
|
|
|
|
it('records a partial success as partial, not ok', async () => {
|
|
// Six riders notified out of eight is not a success. Flattening it would
|
|
// lose exactly the distinction actions.js preserves.
|
|
await reportActed(finding('SlaGuardian', [1, 2]), { ok: true, unreachable: ['DM-2 (no device)'] });
|
|
expect(api.reportFindingActed).toHaveBeenCalledWith('SlaGuardian:notifyRider:1,2', 'partial');
|
|
});
|
|
|
|
it('records a failure', async () => {
|
|
await reportActed(finding('SlaGuardian', [1]), { ok: false });
|
|
expect(api.reportFindingActed).toHaveBeenCalledWith('SlaGuardian:notifyRider:1', 'failed');
|
|
});
|
|
|
|
it('never throws when the backend refuses', async () => {
|
|
api.reportFindingActed.mockRejectedValueOnce(new Error('500'));
|
|
// The action itself already happened and was reported to the operator.
|
|
await expect(reportActed(finding('SlaGuardian', [1]), { ok: true })).resolves.toBeUndefined();
|
|
});
|
|
});
|