wulf-pulse/lib/services/analyzer/itglue-search.test.ts
lorentz 8f8b5ab7be feat: AI ticket analyzer (phases 1-6)
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>
2026-04-29 10:59:40 -04:00

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