405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
|
|
import { describe, it, expect } from 'vitest';
|
||
|
|
import { readFileSync } from 'fs';
|
||
|
|
import { resolve } from 'path';
|
||
|
|
import {
|
||
|
|
preprocessTicket,
|
||
|
|
tagTicketNote,
|
||
|
|
tagTimeEntry,
|
||
|
|
isWorkflowNoise,
|
||
|
|
isEmailNotification,
|
||
|
|
classifyActorType,
|
||
|
|
computeContentHash,
|
||
|
|
type RawTicketBundle,
|
||
|
|
} from './preprocessor';
|
||
|
|
|
||
|
|
// Load the regression fixture once for the file. The fixture is the canonical
|
||
|
|
// shape the data-access layer produces (header + ticket notes + time entries
|
||
|
|
// joined with creator name/email).
|
||
|
|
const FIXTURE_DIR = resolve(__dirname, 'fixtures');
|
||
|
|
const inputFixture = JSON.parse(
|
||
|
|
readFileSync(`${FIXTURE_DIR}/T20260424.0045.input.json`, 'utf8')
|
||
|
|
) as RawTicketBundle;
|
||
|
|
const expectedFixture = JSON.parse(
|
||
|
|
readFileSync(`${FIXTURE_DIR}/T20260424.0045.expected.json`, 'utf8')
|
||
|
|
) as {
|
||
|
|
preprocessor: {
|
||
|
|
filtered_workflow_noise_ids: number[];
|
||
|
|
filtered_email_notification_ids_in_input: number[];
|
||
|
|
retained_note_ids: number[];
|
||
|
|
retained_time_entry_ids: number[];
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('isWorkflowNoise', () => {
|
||
|
|
it('matches by Autotask Administrator resource id', () => {
|
||
|
|
expect(
|
||
|
|
isWorkflowNoise({
|
||
|
|
id: 1,
|
||
|
|
title: null,
|
||
|
|
description: 'x',
|
||
|
|
note_type: null,
|
||
|
|
publish: null,
|
||
|
|
creator_resource_id: 4,
|
||
|
|
creator_name: 'Autotask Administrator',
|
||
|
|
creator_email: null,
|
||
|
|
creator_type: 1,
|
||
|
|
create_date_time: '2026-04-24T00:00:00Z',
|
||
|
|
})
|
||
|
|
).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('matches by title prefix', () => {
|
||
|
|
expect(
|
||
|
|
isWorkflowNoise({
|
||
|
|
id: 1,
|
||
|
|
title: 'Workflow Rule "Foo" fired.',
|
||
|
|
description: 'x',
|
||
|
|
note_type: 13,
|
||
|
|
publish: 1,
|
||
|
|
creator_resource_id: 99,
|
||
|
|
creator_name: null,
|
||
|
|
creator_email: null,
|
||
|
|
creator_type: null,
|
||
|
|
create_date_time: '2026-04-24T00:00:00Z',
|
||
|
|
})
|
||
|
|
).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not match a real ticket note', () => {
|
||
|
|
expect(
|
||
|
|
isWorkflowNoise({
|
||
|
|
id: 1,
|
||
|
|
title: 'Wulf Support Ticket Update -',
|
||
|
|
description: 'real content',
|
||
|
|
note_type: 1,
|
||
|
|
publish: 1,
|
||
|
|
creator_resource_id: 29683311,
|
||
|
|
creator_name: 'Lorentz Hinrichsen',
|
||
|
|
creator_email: 'lorentz@wulfconsulting.com',
|
||
|
|
creator_type: 1,
|
||
|
|
create_date_time: '2026-04-24T00:00:00Z',
|
||
|
|
})
|
||
|
|
).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('isEmailNotification', () => {
|
||
|
|
it('matches "Service Desk Notification" titles', () => {
|
||
|
|
expect(
|
||
|
|
isEmailNotification({
|
||
|
|
id: 1,
|
||
|
|
title: 'Service Desk Notification',
|
||
|
|
description: 'a@b.com, c@d.com',
|
||
|
|
note_type: 2,
|
||
|
|
publish: 4,
|
||
|
|
creator_resource_id: 99,
|
||
|
|
creator_name: null,
|
||
|
|
creator_email: null,
|
||
|
|
creator_type: null,
|
||
|
|
create_date_time: '2026-04-24T00:00:00Z',
|
||
|
|
})
|
||
|
|
).toBe(true);
|
||
|
|
});
|
||
|
|
it('does not match other titles', () => {
|
||
|
|
expect(
|
||
|
|
isEmailNotification({
|
||
|
|
id: 1,
|
||
|
|
title: 'Wulf Support Ticket Update -',
|
||
|
|
description: 'x',
|
||
|
|
note_type: 1,
|
||
|
|
publish: 1,
|
||
|
|
creator_resource_id: 99,
|
||
|
|
creator_name: null,
|
||
|
|
creator_email: null,
|
||
|
|
creator_type: null,
|
||
|
|
create_date_time: '2026-04-24T00:00:00Z',
|
||
|
|
})
|
||
|
|
).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('classifyActorType', () => {
|
||
|
|
it.each([
|
||
|
|
['lorentz@wulfconsulting.com', null, 'wulf_tech'],
|
||
|
|
['cory.houck@wulfconsulting.com', null, 'wulf_tech'],
|
||
|
|
['tlyster@seubert.com', null, 'client_contact'],
|
||
|
|
['rmurphy@vertafore.com', null, 'vendor'],
|
||
|
|
[null, null, 'system'],
|
||
|
|
[undefined, null, 'system'],
|
||
|
|
[null, 4, 'automation'], // Autotask Administrator id
|
||
|
|
['anyone@anywhere.com', 4, 'automation'], // creator id beats domain
|
||
|
|
['mixed@WulfConsulting.COM', null, 'wulf_tech'], // case-insensitive
|
||
|
|
] as const)('email=%s creatorId=%s -> %s', (email, creatorId, expected) => {
|
||
|
|
expect(classifyActorType(email, creatorId)).toBe(expected);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('tagTicketNote', () => {
|
||
|
|
const baseNote = {
|
||
|
|
id: 1,
|
||
|
|
title: 'Wulf Support Ticket Update -',
|
||
|
|
description: 'hello',
|
||
|
|
note_type: 1,
|
||
|
|
creator_type: 1,
|
||
|
|
create_date_time: '2026-04-24T14:34:46.583Z',
|
||
|
|
};
|
||
|
|
|
||
|
|
it('tags publish=1 as customer_facing and puts content in summary_notes', () => {
|
||
|
|
const evt = tagTicketNote({
|
||
|
|
...baseNote,
|
||
|
|
publish: 1,
|
||
|
|
creator_resource_id: 29683311,
|
||
|
|
creator_name: 'Lorentz Hinrichsen',
|
||
|
|
creator_email: 'lorentz@wulfconsulting.com',
|
||
|
|
});
|
||
|
|
expect(evt).toMatchObject({
|
||
|
|
source: 'ticket_note',
|
||
|
|
visibility: 'customer_facing',
|
||
|
|
actor: 'Lorentz Hinrichsen',
|
||
|
|
actor_type: 'wulf_tech',
|
||
|
|
summary_notes: 'hello',
|
||
|
|
});
|
||
|
|
expect(evt?.internal_notes).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('tags publish=2 as internal_only and puts content in internal_notes', () => {
|
||
|
|
const evt = tagTicketNote({
|
||
|
|
...baseNote,
|
||
|
|
publish: 2,
|
||
|
|
creator_resource_id: 29683311,
|
||
|
|
creator_name: 'Lorentz Hinrichsen',
|
||
|
|
creator_email: 'lorentz@wulfconsulting.com',
|
||
|
|
});
|
||
|
|
expect(evt).toMatchObject({
|
||
|
|
visibility: 'internal_only',
|
||
|
|
internal_notes: 'hello',
|
||
|
|
});
|
||
|
|
expect(evt?.summary_notes).toBeUndefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('tagTimeEntry', () => {
|
||
|
|
const baseEntry = {
|
||
|
|
id: 1,
|
||
|
|
resource_id: 30861463,
|
||
|
|
resource_name: 'Cory Houck',
|
||
|
|
resource_email: 'cory.houck@wulfconsulting.com',
|
||
|
|
hours_worked: 0.5,
|
||
|
|
entry_date: '2026-04-24T00:00:00Z',
|
||
|
|
start_date_time: null,
|
||
|
|
end_date_time: '2026-04-24T15:00:00Z',
|
||
|
|
type: 2,
|
||
|
|
};
|
||
|
|
|
||
|
|
it('returns mixed visibility when both notes and internal_notes are present', () => {
|
||
|
|
const evt = tagTimeEntry({
|
||
|
|
...baseEntry,
|
||
|
|
notes: 'public summary',
|
||
|
|
internal_notes: 'internal-only follow-up',
|
||
|
|
});
|
||
|
|
expect(evt).toMatchObject({
|
||
|
|
source: 'time_entry',
|
||
|
|
visibility: 'mixed',
|
||
|
|
summary_notes: 'public summary',
|
||
|
|
internal_notes: 'internal-only follow-up',
|
||
|
|
hours: 0.5,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns customer_facing when only notes are present', () => {
|
||
|
|
const evt = tagTimeEntry({ ...baseEntry, notes: 'public', internal_notes: null });
|
||
|
|
expect(evt?.visibility).toBe('customer_facing');
|
||
|
|
expect(evt?.summary_notes).toBe('public');
|
||
|
|
expect(evt?.internal_notes).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns internal_only when only internal_notes are present', () => {
|
||
|
|
const evt = tagTimeEntry({ ...baseEntry, notes: null, internal_notes: 'internal' });
|
||
|
|
expect(evt?.visibility).toBe('internal_only');
|
||
|
|
expect(evt?.internal_notes).toBe('internal');
|
||
|
|
expect(evt?.summary_notes).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('drops entries with no narrative content', () => {
|
||
|
|
expect(tagTimeEntry({ ...baseEntry, notes: null, internal_notes: null })).toBeNull();
|
||
|
|
expect(tagTimeEntry({ ...baseEntry, notes: ' ', internal_notes: '' })).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('computeContentHash', () => {
|
||
|
|
const evt = {
|
||
|
|
timestamp: '2026-04-24T12:53:50.163Z',
|
||
|
|
actor: 'A',
|
||
|
|
actor_type: 'wulf_tech' as const,
|
||
|
|
source: 'ticket_create' as const,
|
||
|
|
visibility: 'customer_facing' as const,
|
||
|
|
summary_notes: 'x',
|
||
|
|
};
|
||
|
|
it('is deterministic for the same input', () => {
|
||
|
|
expect(computeContentHash([evt], 7, 8, 99)).toBe(
|
||
|
|
computeContentHash([evt], 7, 8, 99)
|
||
|
|
);
|
||
|
|
});
|
||
|
|
it('changes when ticket status changes', () => {
|
||
|
|
expect(computeContentHash([evt], 7, 8, 99)).not.toBe(
|
||
|
|
computeContentHash([evt], 5, 8, 99)
|
||
|
|
);
|
||
|
|
});
|
||
|
|
it('changes when priority changes', () => {
|
||
|
|
expect(computeContentHash([evt], 7, 8, 99)).not.toBe(
|
||
|
|
computeContentHash([evt], 7, 1, 99)
|
||
|
|
);
|
||
|
|
});
|
||
|
|
it('is independent of object key insertion order', () => {
|
||
|
|
const e1 = { ...evt };
|
||
|
|
const e2 = {
|
||
|
|
visibility: evt.visibility,
|
||
|
|
summary_notes: evt.summary_notes,
|
||
|
|
timestamp: evt.timestamp,
|
||
|
|
actor_type: evt.actor_type,
|
||
|
|
actor: evt.actor,
|
||
|
|
source: evt.source,
|
||
|
|
};
|
||
|
|
expect(computeContentHash([e1], 7, 8, 99)).toBe(
|
||
|
|
computeContentHash([e2], 7, 8, 99)
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// Regression: end-to-end against the T20260424.0045 fixture.
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
describe('preprocessTicket — T20260424.0045 fixture', () => {
|
||
|
|
const result = preprocessTicket(inputFixture);
|
||
|
|
|
||
|
|
it('filters the four workflow-rule firings', () => {
|
||
|
|
const filteredIds = expectedFixture.preprocessor.filtered_workflow_noise_ids;
|
||
|
|
for (const id of filteredIds) {
|
||
|
|
expect(result.events.some((e) => e.source === 'ticket_note' && e.actor !== 'Autotask Administrator' || false)).toBe(true);
|
||
|
|
// Confirm none of the filtered IDs survived as events.
|
||
|
|
const survived = inputFixture.notes
|
||
|
|
.filter((n) => filteredIds.includes(n.id))
|
||
|
|
.some((n) => result.events.some((e) => e.timestamp === n.create_date_time));
|
||
|
|
expect(survived).toBe(false);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('filters all "Service Desk Notification" rows', () => {
|
||
|
|
const filteredIds =
|
||
|
|
expectedFixture.preprocessor.filtered_email_notification_ids_in_input;
|
||
|
|
expect(filteredIds.length).toBeGreaterThan(0);
|
||
|
|
const survived = inputFixture.notes
|
||
|
|
.filter((n) => filteredIds.includes(n.id))
|
||
|
|
.some((n) => result.events.some((e) => e.timestamp === n.create_date_time && e.source === 'ticket_note'));
|
||
|
|
expect(survived).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports filtered_noise = workflow + email-notification count', () => {
|
||
|
|
const expected =
|
||
|
|
expectedFixture.preprocessor.filtered_workflow_noise_ids.length +
|
||
|
|
expectedFixture.preprocessor.filtered_email_notification_ids_in_input.length;
|
||
|
|
expect(result.counts.filtered_noise).toBe(expected);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("retains Lorentz's \"I'll take it from here\" note as a tagged event", () => {
|
||
|
|
const lorentzNote = inputFixture.notes.find((n) => n.id === 33738796)!;
|
||
|
|
expect(lorentzNote).toBeDefined();
|
||
|
|
|
||
|
|
const matching = result.events.find(
|
||
|
|
(e) => e.source === 'ticket_note' && e.timestamp === lorentzNote.create_date_time
|
||
|
|
);
|
||
|
|
expect(matching).toBeDefined();
|
||
|
|
expect(matching).toMatchObject({
|
||
|
|
actor: 'Lorentz Hinrichsen',
|
||
|
|
actor_type: 'wulf_tech',
|
||
|
|
visibility: 'customer_facing',
|
||
|
|
});
|
||
|
|
expect(matching?.summary_notes).toContain("take it from here");
|
||
|
|
});
|
||
|
|
|
||
|
|
it('emits 7 events total: 1 ticket_create + 1 retained note + 5 time entries', () => {
|
||
|
|
expect(result.events.length).toBe(7);
|
||
|
|
|
||
|
|
const counts = result.events.reduce<Record<string, number>>((acc, e) => {
|
||
|
|
acc[e.source] = (acc[e.source] ?? 0) + 1;
|
||
|
|
return acc;
|
||
|
|
}, {});
|
||
|
|
expect(counts).toEqual({
|
||
|
|
ticket_create: 1,
|
||
|
|
ticket_note: 1,
|
||
|
|
time_entry: 5,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('produces a chronologically sorted timeline', () => {
|
||
|
|
for (let i = 1; i < result.events.length; i++) {
|
||
|
|
expect(
|
||
|
|
result.events[i].timestamp >= result.events[i - 1].timestamp
|
||
|
|
).toBe(true);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('tags the ticket_create as client_contact based on contact email', () => {
|
||
|
|
const created = result.events.find((e) => e.source === 'ticket_create');
|
||
|
|
expect(created).toMatchObject({
|
||
|
|
actor: 'Tyler Lyster',
|
||
|
|
actor_type: 'client_contact',
|
||
|
|
visibility: 'customer_facing',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('tags time entries 465933, 465961, 466183 as mixed visibility', () => {
|
||
|
|
// These are the entries with both summary and internal notes per the fixture.
|
||
|
|
const expectedMixed = new Set([465933, 465961, 466183]);
|
||
|
|
for (const id of expectedMixed) {
|
||
|
|
const entry = inputFixture.time_entries.find((e) => e.id === id)!;
|
||
|
|
const evt = result.events.find(
|
||
|
|
(e) => e.source === 'time_entry' && e.hours === entry.hours_worked && e.summary_notes && e.internal_notes
|
||
|
|
);
|
||
|
|
expect(evt, `time_entry ${id} should produce a mixed event`).toBeDefined();
|
||
|
|
expect(evt?.visibility).toBe('mixed');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports counts that add up', () => {
|
||
|
|
const { customer_facing, internal_only, mixed, total_events } = result.counts;
|
||
|
|
expect(customer_facing + internal_only + mixed).toBe(total_events);
|
||
|
|
expect(total_events).toBe(7);
|
||
|
|
// 1 ticket_create + 1 ticket_note (publish=1) + 2 time_entries with only notes = 4 customer_facing
|
||
|
|
expect(customer_facing).toBe(4);
|
||
|
|
expect(internal_only).toBe(0);
|
||
|
|
expect(mixed).toBe(3);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('produces a stable content_hash across re-runs', () => {
|
||
|
|
const a = preprocessTicket(inputFixture).content_hash;
|
||
|
|
const b = preprocessTicket(inputFixture).content_hash;
|
||
|
|
expect(a).toBe(b);
|
||
|
|
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('changes content_hash if a meaningful field changes', () => {
|
||
|
|
const original = preprocessTicket(inputFixture).content_hash;
|
||
|
|
const modified = preprocessTicket({
|
||
|
|
...inputFixture,
|
||
|
|
ticket: { ...inputFixture.ticket, status: 5 },
|
||
|
|
}).content_hash;
|
||
|
|
expect(modified).not.toBe(original);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('populates the LLM-bound header correctly', () => {
|
||
|
|
expect(result.header).toMatchObject({
|
||
|
|
ticket_number: 'T20260424.0045',
|
||
|
|
autotask_ticket_id: 680282,
|
||
|
|
status_label: 'Waiting Customer',
|
||
|
|
priority_label: 'Minor Service',
|
||
|
|
queue: 'Level 2 Support',
|
||
|
|
account_name: 'Seubert and Associates',
|
||
|
|
contact_name: 'Tyler Lyster',
|
||
|
|
contact_email: 'tlyster@seubert.com',
|
||
|
|
resolved_at: null,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|