import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { evaluateCustomerLifecycle, type CustomerLifecycleInput, } from '../src/lifecycle'; const NOW = new Date('2026-08-13T12:00:00.000Z'); const ACCOUNT_ID = '10000000-0000-4000-8000-000000000001'; const DEAL_ID = '20000000-0000-4000-8000-000000000001'; function input(overrides: Partial = {}): CustomerLifecycleInput { return { accountId: ACCOUNT_ID, deals: [], requests: [], allocations: [], contracts: [], obligations: [], lastActivityAt: NOW, ...overrides, }; } describe('customer lifecycle rules', () => { it('uses allocation state and a fixed clock rather than treating a hold as deployment', () => { const base = { id: '30000000-0000-4000-8000-000000000001', demandDealId: DEAL_ID, gpuHours: 1_000, startsAt: new Date('2026-08-01T00:00:00.000Z'), endsAt: new Date('2026-09-01T00:00:00.000Z'), }; const held = evaluateCustomerLifecycle(input({ allocations: [{ ...base, status: 'planned', holdExpiresAt: new Date('2026-08-14T00:00:00.000Z') }] }), NOW); const deployed = evaluateCustomerLifecycle(input({ allocations: [{ ...base, status: 'active' }] }), NOW); assert.equal(held.relationshipState, 'prospect'); assert.equal(held.heldCapacityGpuHours, 1_000); assert.equal(deployed.relationshipState, 'deployed'); }); it('suppresses ending-capacity risk when a future reservation covers the same deal', () => { const current = { id: '30000000-0000-4000-8000-000000000001', demandDealId: DEAL_ID, status: 'active' as const, gpuHours: 1_000, startsAt: new Date('2026-07-01T00:00:00.000Z'), endsAt: new Date('2026-08-20T00:00:00.000Z'), }; const future = { ...current, id: '30000000-0000-4000-8000-000000000002', status: 'committed' as const, startsAt: new Date('2026-08-20T00:00:00.000Z'), endsAt: new Date('2026-10-01T00:00:00.000Z'), }; const result = evaluateCustomerLifecycle(input({ allocations: [current, future] }), NOW); assert.equal(result.signals.some((signal) => signal.code === 'allocation_ending_uncovered'), false); }); it('describes missing activity as stale CRM evidence, never customer disengagement', () => { const result = evaluateCustomerLifecycle(input({ lastActivityAt: null }), NOW); const stale = result.signals.find((signal) => signal.code === 'crm_evidence_stale_90'); assert.match(stale?.explanation ?? '', /CRM evidence is stale/); assert.match(stale?.explanation ?? '', /does not establish customer disengagement/); }); it('orders explainable weighted signals deterministically', () => { const result = evaluateCustomerLifecycle(input({ deals: [{ id: DEAL_ID, stage: 'expansion', productLine: 'compute_reserved', msaExecuted: true, lastActivityAt: NOW }], requests: [{ id: '40000000-0000-4000-8000-000000000001', demandDealId: DEAL_ID, totalGpuHours: 500 }], contracts: [{ id: '50000000-0000-4000-8000-000000000001', status: 'executed', isAutoRenew: true, noticeDays: 30, expiresAt: new Date('2026-09-01T00:00:00.000Z') }], }), NOW); assert.deepEqual(result.signals.map((signal) => signal.weight), [...result.signals.map((signal) => signal.weight)].sort((a, b) => b - a)); assert.ok(result.signals.every((signal) => signal.explanation && signal.sourceRefs.length)); assert.equal(result.rulesetVersion, 'growth-r1-2026-08-13'); assert.equal(result.computedAt, NOW.toISOString()); }); it('labels allocation volume only as sold or held capacity', () => { const result = evaluateCustomerLifecycle(input(), NOW); const serialized = JSON.stringify(result); assert.doesNotMatch(serialized, /workload utilization|customer utilization/i); assert.ok('soldCapacityGpuHours' in result); assert.ok('heldCapacityGpuHours' in result); }); });