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

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();
});
});