Multi-stage LLM pipeline that produces structured analyses of Autotask tickets from local Postgres. Migration 069 + Zod schemas, Stage 0 preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages 1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker (opt-in autostart), 6 API routes, 3 frontend pages, share-row persistence (email send deferred to phase 7). 128 vitest tests, tsc clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md. Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered entities so the analyzer's local mirror stays current via scheduler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
8.8 KiB
TypeScript
256 lines
8.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { itglueSearch, resolveOrgId, _ITGLUE_SEARCH_INTERNALS } from './itglue-search';
|
|
import * as itglueClientModule from '@/lib/services/itglue-client';
|
|
|
|
function fakeITGlueClient(overrides?: Partial<{
|
|
getOrganizations: ReturnType<typeof vi.fn>;
|
|
getConfigurations: ReturnType<typeof vi.fn>;
|
|
getFlexibleAssets: ReturnType<typeof vi.fn>;
|
|
}>) {
|
|
return {
|
|
getOrganizations: overrides?.getOrganizations ?? vi.fn().mockResolvedValue([]),
|
|
getConfigurations: overrides?.getConfigurations ?? vi.fn().mockResolvedValue([]),
|
|
getFlexibleAssets: overrides?.getFlexibleAssets ?? vi.fn().mockResolvedValue([]),
|
|
};
|
|
}
|
|
|
|
let getClientSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
// Default: no org, no docs.
|
|
getClientSpy = vi
|
|
.spyOn(itglueClientModule, 'getITGlueClient')
|
|
.mockReturnValue(fakeITGlueClient() as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
getClientSpy.mockRestore();
|
|
});
|
|
|
|
describe('resolveOrgId', () => {
|
|
it('returns null when no org matches', async () => {
|
|
getClientSpy.mockReturnValue(fakeITGlueClient() as any);
|
|
const result = await resolveOrgId('Nonexistent Co');
|
|
expect(result.org_id).toBeNull();
|
|
expect(result.alias_used).toBe(false);
|
|
});
|
|
|
|
it('uses the live API exact-match when one is found', async () => {
|
|
getClientSpy.mockReturnValue(
|
|
fakeITGlueClient({
|
|
getOrganizations: vi.fn().mockResolvedValue([
|
|
{ id: '789', name: 'Other Co' },
|
|
{ id: '123', name: 'Acme Industries' },
|
|
]),
|
|
}) as any
|
|
);
|
|
const result = await resolveOrgId('Acme Industries');
|
|
expect(result.org_id).toBe('123');
|
|
expect(result.org_name).toBe('Acme Industries');
|
|
expect(result.alias_used).toBe(false);
|
|
});
|
|
|
|
it('returns null without throwing if the IT Glue client errors', async () => {
|
|
getClientSpy.mockReturnValue(
|
|
fakeITGlueClient({
|
|
getOrganizations: vi.fn().mockRejectedValue(new Error('502 Bad Gateway')),
|
|
}) as any
|
|
);
|
|
const result = await resolveOrgId('Anything');
|
|
expect(result.org_id).toBeNull();
|
|
expect(result.alias_used).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('itglueSearch', () => {
|
|
it('returns an empty doc set when the org cannot be resolved', async () => {
|
|
const result = await itglueSearch({ org_name: 'Unknown Co', hints: [] });
|
|
expect(result.org_id).toBeNull();
|
|
expect(result.docs).toEqual([]);
|
|
});
|
|
|
|
it('returns capped, redacted doc snippets when the org is found', async () => {
|
|
getClientSpy.mockReturnValue(
|
|
fakeITGlueClient({
|
|
getOrganizations: vi.fn().mockResolvedValue([{ id: '42', name: 'Acme' }]),
|
|
getConfigurations: vi.fn().mockResolvedValue([
|
|
{
|
|
id: 'c1',
|
|
name: 'AMS360 Server',
|
|
hostname: 'ams360.acme.local',
|
|
primaryIp: '10.0.0.1',
|
|
macAddress: 'aa:bb:cc:dd:ee:ff',
|
|
serialNumber: 'SN-1234',
|
|
assetTag: null,
|
|
configurationTypeId: null,
|
|
configurationTypeName: 'Server',
|
|
configurationStatusId: null,
|
|
configurationStatusName: 'Active',
|
|
manufacturerId: null,
|
|
manufacturerName: 'Dell',
|
|
modelId: null,
|
|
modelName: 'PowerEdge',
|
|
operatingSystemId: null,
|
|
operatingSystemName: 'Windows Server 2022',
|
|
notes: 'admin password is hunter2 — do not share',
|
|
purchasedAt: null,
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
organizationId: 42,
|
|
organizationName: 'Acme',
|
|
},
|
|
]),
|
|
getFlexibleAssets: vi.fn().mockResolvedValue([
|
|
{
|
|
id: 'f1',
|
|
name: 'AMS360 API Integration',
|
|
organizationId: 42,
|
|
organizationName: 'Acme',
|
|
flexibleAssetTypeId: 1,
|
|
flexibleAssetTypeName: 'API Integration',
|
|
traits: {
|
|
endpoint: 'https://ams360.acme.com/api',
|
|
api_key: 'sk_live_supersecret',
|
|
notes: 'Used for the Outmarket integration',
|
|
},
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
},
|
|
]),
|
|
}) as any
|
|
);
|
|
|
|
const result = await itglueSearch({
|
|
org_name: 'Acme',
|
|
hints: ['AMS360 API'],
|
|
});
|
|
|
|
expect(result.org_id).toBe('42');
|
|
expect(result.docs.length).toBe(2);
|
|
|
|
// Configuration snippet contains the hostname but the password subtree
|
|
// (under the benign key 'notes') is preserved as-is — note that 'notes'
|
|
// doesn't match the sensitive-key pattern, so the substring 'hunter2'
|
|
// would survive. The redaction guarantee is about FIELD KEYS, not free
|
|
// text. This test asserts that contract.
|
|
const cfg = result.docs.find((d) => d.doc_type === 'configuration')!;
|
|
expect(cfg.snippet).toContain('ams360.acme.local');
|
|
|
|
// Flexible-asset api_key MUST be redacted because the trait key matches
|
|
// the sensitive-key pattern.
|
|
const flex = result.docs.find((d) => d.doc_type === 'flexible_asset')!;
|
|
expect(flex.snippet).not.toContain('sk_live_supersecret');
|
|
expect(flex.snippet).toContain('[REDACTED]');
|
|
// But the endpoint (benign key) should pass through.
|
|
expect(flex.snippet).toContain('ams360.acme.com');
|
|
});
|
|
|
|
it('caps each doc snippet at PER_DOC_BODY_CHAR_CAP', async () => {
|
|
const huge = 'X'.repeat(_ITGLUE_SEARCH_INTERNALS.PER_DOC_BODY_CHAR_CAP * 2);
|
|
getClientSpy.mockReturnValue(
|
|
fakeITGlueClient({
|
|
getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Big' }]),
|
|
getConfigurations: vi.fn().mockResolvedValue([
|
|
{
|
|
id: 'big-cfg',
|
|
name: 'Big Config',
|
|
hostname: null,
|
|
primaryIp: null,
|
|
macAddress: null,
|
|
serialNumber: null,
|
|
assetTag: null,
|
|
configurationTypeId: null,
|
|
configurationTypeName: null,
|
|
configurationStatusId: null,
|
|
configurationStatusName: null,
|
|
manufacturerId: null,
|
|
manufacturerName: null,
|
|
modelId: null,
|
|
modelName: null,
|
|
operatingSystemId: null,
|
|
operatingSystemName: null,
|
|
notes: huge,
|
|
purchasedAt: null,
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
organizationId: 1,
|
|
organizationName: 'Big',
|
|
},
|
|
]),
|
|
}) as any
|
|
);
|
|
|
|
const result = await itglueSearch({ org_name: 'Big', hints: [] });
|
|
expect(result.docs[0].snippet.length).toBeLessThanOrEqual(
|
|
_ITGLUE_SEARCH_INTERNALS.PER_DOC_BODY_CHAR_CAP
|
|
);
|
|
expect(result.docs[0].snippet).toContain('truncated');
|
|
});
|
|
|
|
it('caps total docs at MAX_DOCS_RETURNED', async () => {
|
|
const many = Array.from({ length: 25 }, (_, i) => ({
|
|
id: `c${i}`,
|
|
name: `Config ${i}`,
|
|
hostname: null,
|
|
primaryIp: null,
|
|
macAddress: null,
|
|
serialNumber: null,
|
|
assetTag: null,
|
|
configurationTypeId: null,
|
|
configurationTypeName: null,
|
|
configurationStatusId: null,
|
|
configurationStatusName: null,
|
|
manufacturerId: null,
|
|
manufacturerName: null,
|
|
modelId: null,
|
|
modelName: null,
|
|
operatingSystemId: null,
|
|
operatingSystemName: null,
|
|
notes: '',
|
|
purchasedAt: null,
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
organizationId: 1,
|
|
organizationName: 'Many',
|
|
}));
|
|
|
|
getClientSpy.mockReturnValue(
|
|
fakeITGlueClient({
|
|
getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Many' }]),
|
|
getConfigurations: vi.fn().mockResolvedValue(many),
|
|
}) as any
|
|
);
|
|
|
|
const result = await itglueSearch({ org_name: 'Many', hints: [] });
|
|
expect(result.docs.length).toBeLessThanOrEqual(
|
|
_ITGLUE_SEARCH_INTERNALS.MAX_DOCS_RETURNED
|
|
);
|
|
});
|
|
|
|
it('tolerates per-call failures (configurations errors, flex still returns)', async () => {
|
|
getClientSpy.mockReturnValue(
|
|
fakeITGlueClient({
|
|
getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Mixed' }]),
|
|
getConfigurations: vi.fn().mockRejectedValue(new Error('boom')),
|
|
getFlexibleAssets: vi.fn().mockResolvedValue([
|
|
{
|
|
id: 'f1',
|
|
name: 'Runbook',
|
|
organizationId: 1,
|
|
organizationName: 'Mixed',
|
|
flexibleAssetTypeId: 1,
|
|
flexibleAssetTypeName: 'Runbook',
|
|
traits: { steps: 'Step 1, step 2' },
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-04-01T00:00:00Z',
|
|
},
|
|
]),
|
|
}) as any
|
|
);
|
|
|
|
const result = await itglueSearch({ org_name: 'Mixed', hints: [] });
|
|
expect(result.org_id).toBe('1');
|
|
expect(result.docs.length).toBe(1);
|
|
expect(result.docs[0].doc_type).toBe('flexible_asset');
|
|
});
|
|
});
|