Populates platform/business-calendars, orchestration/sla, and orchestration/escalation (all thin stubs until now) with the real engine: - business-calendars: a luxon-based day-by-day calendar walk (addBusinessMinutes/isWithinWorkingHours) excluding non-working hours, weekends, and holidays — replacing the naive createdAt+hours stub FR-004 explicitly forbids. - sla: most-specific SLAPolicy resolution (product/category/problemType/ priority, wildcard-or-exact-match, specificity-count + updatedAt tiebreak), SLARun creation on the first real publish of the long-unused TICKET_ASSIGNED domain event, durable pause/resume via an absolute-timestamp shift (no in-memory state, verified across a real buildApp() restart), and a repeatable BullMQ breach-detection sweep (src/jobs/sla, itself a previously-unregistered stub) that is directly callable for tests, not only reachable through a running worker. - escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types storable, only resolution_breach/first_response_breach evaluated), breach-triggered and manual escalation both funnel through one EscalationEvent + scoped re-assignment path. AssignmentEngine (007) gains assignToSpecificNode — a new, explicitly node-scoped entry point, since escalation must never let 007's general resolution re-derive a different node than the one a rule or a caller targeted. Two small pre-existing scaffold gaps were closed along the way: CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/ ESCALATION_TRIGGERED were defined since earlier phases but never published by any code. Verified against throwaway Docker Postgres/Redis (typecheck, lint, architecture-check all clean; 148/150 relevant tests pass — the 2 failures are pre-existing, MinIO-dependent, and unrelated to this feature). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { SlaPolicyResolverService } from '@/modules/orchestration/sla/service/sla-policy-resolver.service';
|
|
import { SLAPolicy } from '@prisma/client';
|
|
|
|
function policy(overrides: Partial<SLAPolicy>): SLAPolicy {
|
|
return {
|
|
id: 'p1',
|
|
name: 'test',
|
|
productId: null,
|
|
categoryId: null,
|
|
problemTypeId: null,
|
|
priority: null,
|
|
firstResponseMinutes: 60,
|
|
investigationMinutes: null,
|
|
resolutionMinutes: 480,
|
|
customerResponseMinutes: null,
|
|
businessCalendarId: null,
|
|
active: true,
|
|
createdAt: new Date('2026-01-01'),
|
|
updatedAt: new Date('2026-01-01'),
|
|
...overrides,
|
|
} as SLAPolicy;
|
|
}
|
|
|
|
function fakeRepo(candidates: SLAPolicy[]) {
|
|
return { findActiveCandidates: vi.fn().mockResolvedValue(candidates) } as never;
|
|
}
|
|
|
|
describe('SlaPolicyResolverService.findApplicablePolicy', () => {
|
|
it('prefers a policy with more matching specific fields over a global wildcard policy', async () => {
|
|
const global = policy({ id: 'global' });
|
|
const specific = policy({ id: 'specific', productId: 'prod-1' });
|
|
const resolver = new SlaPolicyResolverService(fakeRepo([global, specific]));
|
|
|
|
const result = await resolver.findApplicablePolicy({ productId: 'prod-1' });
|
|
expect(result?.id).toBe('specific');
|
|
});
|
|
|
|
it('excludes a policy whose scope field is set but does not match the ticket', async () => {
|
|
const wrongProduct = policy({ id: 'wrong', productId: 'prod-2' });
|
|
const resolver = new SlaPolicyResolverService(fakeRepo([wrongProduct]));
|
|
|
|
const result = await resolver.findApplicablePolicy({ productId: 'prod-1' });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('treats every unset scope field as a wildcard independently', async () => {
|
|
const productOnly = policy({ id: 'product-only', productId: 'prod-1' });
|
|
const resolver = new SlaPolicyResolverService(fakeRepo([productOnly]));
|
|
|
|
const result = await resolver.findApplicablePolicy({
|
|
productId: 'prod-1',
|
|
categoryId: 'cat-99',
|
|
priority: 'urgent',
|
|
});
|
|
expect(result?.id).toBe('product-only');
|
|
});
|
|
|
|
it('breaks a specificity tie by the most recently updated policy', async () => {
|
|
const older = policy({
|
|
id: 'older',
|
|
productId: 'prod-1',
|
|
updatedAt: new Date('2026-01-01'),
|
|
});
|
|
const newer = policy({
|
|
id: 'newer',
|
|
productId: 'prod-1',
|
|
updatedAt: new Date('2026-06-01'),
|
|
});
|
|
const resolver = new SlaPolicyResolverService(fakeRepo([older, newer]));
|
|
|
|
const result = await resolver.findApplicablePolicy({ productId: 'prod-1' });
|
|
expect(result?.id).toBe('newer');
|
|
});
|
|
|
|
it('returns null when no active policy matches', async () => {
|
|
const resolver = new SlaPolicyResolverService(fakeRepo([]));
|
|
const result = await resolver.findApplicablePolicy({ productId: 'prod-1' });
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|