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