- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {
|
|
extractExplicitFromText,
|
|
detectProblemTicket,
|
|
TICKET_NUMBER_REGEX,
|
|
MAX_EXPLICIT_LINKS,
|
|
discoverExplicitLinks,
|
|
} from './link-discovery';
|
|
import type { RawTicketBundle } from './preprocessor';
|
|
|
|
vi.mock('@/lib/services/postgres-client', () => ({
|
|
default: {
|
|
query: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
const mockedQuery = postgresClient.query as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
function bundle(partial: Partial<RawTicketBundle['ticket']> = {}): RawTicketBundle {
|
|
return {
|
|
ticket: {
|
|
id: 1,
|
|
ticket_number: 'T20260430.0084',
|
|
title: 'Master problem ticket — Hynes',
|
|
description: null,
|
|
status: 1,
|
|
status_label: 'New',
|
|
priority: 1,
|
|
priority_label: 'High',
|
|
queue_id: null,
|
|
queue_label: null,
|
|
company_id: 100,
|
|
company_name: 'Hynes Industries',
|
|
contact_id: null,
|
|
contact_name: null,
|
|
contact_email: null,
|
|
assigned_resource_id: null,
|
|
assignee_name: null,
|
|
assignee_email: null,
|
|
create_date: '2026-04-30T12:00:00Z',
|
|
last_activity_date: '2026-04-30T12:00:00Z',
|
|
resolved_date_time: null,
|
|
problem_ticket_id: null,
|
|
...partial,
|
|
},
|
|
notes: [],
|
|
time_entries: [],
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedQuery.mockReset();
|
|
});
|
|
|
|
describe('TICKET_NUMBER_REGEX', () => {
|
|
it('matches the canonical Pulse format', () => {
|
|
const m = 'see T20260430.0084 and T20260427.0142'.match(TICKET_NUMBER_REGEX);
|
|
expect(m).toEqual(['T20260430.0084', 'T20260427.0142']);
|
|
});
|
|
|
|
it('does not match invalid lengths', () => {
|
|
expect('T2026.0084'.match(TICKET_NUMBER_REGEX)).toBeNull();
|
|
expect('T20260430.84'.match(TICKET_NUMBER_REGEX)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('extractExplicitFromText', () => {
|
|
it('returns medium-confidence refs from a free-text mention', () => {
|
|
const r = extractExplicitFromText(
|
|
'See T20260427.0142 for context.',
|
|
'note_mention'
|
|
);
|
|
expect(r.refs).toEqual([
|
|
{ ticket_number: 'T20260427.0142', source: 'note_mention', confidence: 'medium' },
|
|
]);
|
|
expect(r.hasRelatedTicketsSection).toBe(false);
|
|
});
|
|
|
|
it('marks refs in a RELATED TICKETS: block as high confidence', () => {
|
|
const text = `Master problem ticket.
|
|
|
|
RELATED TICKETS:
|
|
T20260428.0053 — Allison Leone packet loss (OPEN)
|
|
T20260427.0142 — George Droder Zoom dropping (OPEN)
|
|
|
|
AFFECTED USERS:
|
|
Allison, George`;
|
|
const r = extractExplicitFromText(text, 'description_mention');
|
|
expect(r.hasRelatedTicketsSection).toBe(true);
|
|
expect(r.refs).toEqual([
|
|
{
|
|
ticket_number: 'T20260428.0053',
|
|
source: 'related_tickets_section',
|
|
confidence: 'high',
|
|
},
|
|
{
|
|
ticket_number: 'T20260427.0142',
|
|
source: 'related_tickets_section',
|
|
confidence: 'high',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('does not mark refs after the RELATED TICKETS section ends as high', () => {
|
|
const text = `RELATED TICKETS:
|
|
T20260428.0053 — first
|
|
|
|
OTHER NOTES:
|
|
Background investigation found T20260101.0001 was a duplicate.`;
|
|
const r = extractExplicitFromText(text, 'description_mention');
|
|
const high = r.refs.find((x) => x.ticket_number === 'T20260428.0053');
|
|
const other = r.refs.find((x) => x.ticket_number === 'T20260101.0001');
|
|
expect(high?.confidence).toBe('high');
|
|
expect(other?.confidence).toBe('medium');
|
|
expect(other?.source).toBe('description_mention');
|
|
});
|
|
|
|
it('dedupes within a single text', () => {
|
|
const r = extractExplicitFromText(
|
|
'T20260427.0142 first, T20260427.0142 again, and T20260427.0142 once more.',
|
|
'note_mention'
|
|
);
|
|
expect(r.refs).toHaveLength(1);
|
|
});
|
|
|
|
it('returns empty for empty input', () => {
|
|
expect(extractExplicitFromText('', 'note_mention').refs).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('detectProblemTicket', () => {
|
|
it('flags master-problem-ticket title', () => {
|
|
const r = detectProblemTicket(
|
|
bundle({ title: 'Master problem ticket — recurring degradation' }),
|
|
false
|
|
);
|
|
expect(r.isProblemTicket).toBe(true);
|
|
expect(r.signals).toContain('title:master_problem_ticket');
|
|
});
|
|
|
|
it('flags problem-ticket title', () => {
|
|
const r = detectProblemTicket(
|
|
bundle({ title: 'Problem ticket: keyboard outage' }),
|
|
false
|
|
);
|
|
expect(r.isProblemTicket).toBe(true);
|
|
expect(r.signals).toContain('title:problem_ticket');
|
|
});
|
|
|
|
it('flags presence of RELATED TICKETS section', () => {
|
|
const r = detectProblemTicket(
|
|
bundle({ title: 'Plain ticket' }),
|
|
true
|
|
);
|
|
expect(r.isProblemTicket).toBe(true);
|
|
expect(r.signals).toContain('description:related_tickets_section');
|
|
});
|
|
|
|
it('flags problem_ticket_id column', () => {
|
|
const r = detectProblemTicket(
|
|
bundle({ title: 'Plain ticket', problem_ticket_id: 999 }),
|
|
false
|
|
);
|
|
expect(r.isProblemTicket).toBe(true);
|
|
expect(r.signals).toContain('column:problem_ticket_id');
|
|
});
|
|
|
|
it('returns false when none of the signals are present', () => {
|
|
const r = detectProblemTicket(bundle({ title: 'Plain ticket' }), false);
|
|
expect(r.isProblemTicket).toBe(false);
|
|
expect(r.signals).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('discoverExplicitLinks', () => {
|
|
it('skips self-references and unknown tickets', async () => {
|
|
const b = bundle({
|
|
description:
|
|
'master ref T20260430.0084 (self), real ref T20260428.0053, ghost T20260101.9999',
|
|
});
|
|
// First call: meta lookup. Only T20260428.0053 exists.
|
|
mockedQuery.mockResolvedValueOnce({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
ticket_number: 'T20260428.0053',
|
|
title: 'Allison Leone',
|
|
status_label: 'Open',
|
|
last_activity_date: '2026-04-30T10:00:00Z',
|
|
},
|
|
],
|
|
});
|
|
const r = await discoverExplicitLinks(b);
|
|
expect(r.explicit).toHaveLength(1);
|
|
expect(r.explicit[0].ticket_number).toBe('T20260428.0053');
|
|
});
|
|
|
|
it('caps explicit refs at MAX_EXPLICIT_LINKS', async () => {
|
|
const refs = Array.from({ length: 30 }, (_, i) => `T2026010${i}.0001`).join(', ');
|
|
const b = bundle({ description: `Many refs: ${refs}` });
|
|
// Return meta for all 15 it queries.
|
|
mockedQuery.mockImplementationOnce(async (_sql: string, params: unknown[]) => {
|
|
const numbers = params[0] as string[];
|
|
expect(numbers.length).toBeLessThanOrEqual(MAX_EXPLICIT_LINKS);
|
|
return {
|
|
rowCount: numbers.length,
|
|
rows: numbers.map((n) => ({
|
|
ticket_number: n,
|
|
title: 't',
|
|
status_label: 'Open',
|
|
last_activity_date: '2026-04-30T10:00:00Z',
|
|
})),
|
|
};
|
|
});
|
|
const r = await discoverExplicitLinks(b);
|
|
expect(r.explicit.length).toBeLessThanOrEqual(MAX_EXPLICIT_LINKS);
|
|
});
|
|
|
|
it('resolves problem_ticket_id and dedupes against text mention', async () => {
|
|
const b = bundle({
|
|
description: 'See T20260427.0142 for context',
|
|
problem_ticket_id: 555,
|
|
});
|
|
// Call 1: resolve problem_ticket_id → ticket_number.
|
|
mockedQuery.mockResolvedValueOnce({
|
|
rowCount: 1,
|
|
rows: [{ ticket_number: 'T20260427.0142' }],
|
|
});
|
|
// Call 2: meta lookup.
|
|
mockedQuery.mockResolvedValueOnce({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
ticket_number: 'T20260427.0142',
|
|
title: 'George Droder',
|
|
status_label: 'Open',
|
|
last_activity_date: '2026-04-29T10:00:00Z',
|
|
},
|
|
],
|
|
});
|
|
const r = await discoverExplicitLinks(b);
|
|
// Same ticket from two sources should appear once at the higher confidence.
|
|
expect(r.explicit).toHaveLength(1);
|
|
expect(r.explicit[0].confidence).toBe('high');
|
|
expect(r.explicit[0].source).toBe('problem_ticket_id');
|
|
});
|
|
|
|
it('sorts high confidence first, then by activity date desc', async () => {
|
|
const b = bundle({
|
|
description: `Master.
|
|
|
|
RELATED TICKETS:
|
|
T20260428.0053 — high
|
|
|
|
Body mention: T20260427.0142 — medium`,
|
|
});
|
|
mockedQuery.mockResolvedValueOnce({
|
|
rowCount: 2,
|
|
rows: [
|
|
{
|
|
ticket_number: 'T20260427.0142',
|
|
title: 'a',
|
|
status_label: 'Open',
|
|
last_activity_date: '2026-04-30T10:00:00Z',
|
|
},
|
|
{
|
|
ticket_number: 'T20260428.0053',
|
|
title: 'b',
|
|
status_label: 'Open',
|
|
last_activity_date: '2026-04-29T10:00:00Z',
|
|
},
|
|
],
|
|
});
|
|
const r = await discoverExplicitLinks(b);
|
|
expect(r.explicit.map((x) => x.ticket_number)).toEqual([
|
|
'T20260428.0053',
|
|
'T20260427.0142',
|
|
]);
|
|
expect(r.isProblemTicket).toBe(true);
|
|
expect(r.problemTicketSignals).toContain('description:related_tickets_section');
|
|
});
|
|
|
|
it('returns empty when there are no refs and no signals', async () => {
|
|
const b = bundle({ description: 'No ticket refs in here.', title: 'Plain ticket' });
|
|
mockedQuery.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
|
const r = await discoverExplicitLinks(b);
|
|
expect(r.explicit).toEqual([]);
|
|
expect(r.isProblemTicket).toBe(false);
|
|
});
|
|
|
|
it('parses refs out of retained notes too, ignoring workflow noise', async () => {
|
|
const b = bundle({
|
|
description: null,
|
|
title: 'Plain',
|
|
});
|
|
// Inject a workflow-noise note (filtered) and a real note (kept).
|
|
b.notes = [
|
|
{
|
|
id: 1,
|
|
title: 'Workflow Rule "Foo" fired.',
|
|
description: 'Mentions T20260101.0001 but should be ignored',
|
|
note_type: 13,
|
|
publish: 1,
|
|
creator_resource_id: 4,
|
|
creator_name: 'Autotask Administrator',
|
|
creator_email: null,
|
|
creator_type: 1,
|
|
create_date_time: '2026-04-30T12:00:00Z',
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Tech note',
|
|
description: 'See T20260427.0142 for the related issue',
|
|
note_type: 1,
|
|
publish: 1,
|
|
creator_resource_id: 50,
|
|
creator_name: 'Tech',
|
|
creator_email: 'tech@wulfconsulting.com',
|
|
creator_type: 1,
|
|
create_date_time: '2026-04-30T13:00:00Z',
|
|
},
|
|
];
|
|
mockedQuery.mockResolvedValueOnce({
|
|
rowCount: 1,
|
|
rows: [
|
|
{
|
|
ticket_number: 'T20260427.0142',
|
|
title: 'real',
|
|
status_label: 'Open',
|
|
last_activity_date: '2026-04-30T10:00:00Z',
|
|
},
|
|
],
|
|
});
|
|
const r = await discoverExplicitLinks(b);
|
|
expect(r.explicit.map((x) => x.ticket_number)).toEqual(['T20260427.0142']);
|
|
expect(r.explicit[0].source).toBe('note_mention');
|
|
});
|
|
});
|