2026-09-03 13:02:05 +05:30
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
|
|
|
import { buildApp } from '@/app';
|
|
|
|
|
import { prismaClient } from '@/infrastructure/database';
|
|
|
|
|
import { FastifyInstance } from 'fastify';
|
|
|
|
|
import { slaService } from '@/modules/orchestration/sla';
|
|
|
|
|
import {
|
|
|
|
|
encryptCredential,
|
|
|
|
|
generateCredentialSecret,
|
|
|
|
|
issueIntegrationToken,
|
|
|
|
|
} from '@/modules/catalog/products';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Covers specs/008-sla-escalation/quickstart.md Scenarios 1-6 against a real Postgres/Redis —
|
|
|
|
|
* one product's tickets through SLA policy resolution, calendar-aware run creation, durable
|
|
|
|
|
* pause/resume (including a genuine buildApp() restart, Constitution Principle VII), breach
|
|
|
|
|
* detection, and both automatic and manual escalation.
|
|
|
|
|
*/
|
|
|
|
|
describe('SLA and escalation — full flow (User Stories 1-6)', () => {
|
|
|
|
|
let app: FastifyInstance;
|
|
|
|
|
const externalProductId = `TEST_SLA_PROD_${Date.now()}`;
|
|
|
|
|
const skillTag = `sla_skill_${Date.now()}`;
|
|
|
|
|
let productId: string;
|
|
|
|
|
let teamId: string;
|
|
|
|
|
let agentAId: string;
|
|
|
|
|
let agentBId: string;
|
|
|
|
|
let nodeAId: string;
|
|
|
|
|
let nodeBId: string;
|
|
|
|
|
let secret: string;
|
|
|
|
|
let globalPolicyId: string;
|
|
|
|
|
let productPolicyId: string;
|
|
|
|
|
const createdTicketIds: string[] = [];
|
|
|
|
|
|
|
|
|
|
async function createTicket(): Promise<string> {
|
|
|
|
|
// 002-saas-integration: tokens are single-use (jti replay protection) — a fresh one per
|
|
|
|
|
// ticket, matching the trust-boundary contract, not a shared token reused across requests.
|
|
|
|
|
const token = issueIntegrationToken(secret, {
|
|
|
|
|
externalProductId,
|
|
|
|
|
tenantId: 'tenant-1',
|
|
|
|
|
userId: 'user-1',
|
|
|
|
|
});
|
|
|
|
|
const created = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/v1/support/requests',
|
|
|
|
|
headers: { authorization: `Bearer ${token}` },
|
|
|
|
|
payload: {
|
|
|
|
|
productId: externalProductId,
|
|
|
|
|
tenantId: 'tenant-1',
|
|
|
|
|
userId: 'user-1',
|
|
|
|
|
source: 'test',
|
|
|
|
|
problem: `Needs a human ${Date.now()}-${Math.random()}`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const ticketId = created.json().data.ticketId as string;
|
|
|
|
|
createdTicketIds.push(ticketId);
|
|
|
|
|
return ticketId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function escalateAndAssign(ticketId: string): Promise<void> {
|
|
|
|
|
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
|
|
|
|
await app.inject({
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
url: `/tickets/${ticketId}/status`,
|
|
|
|
|
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
app = await buildApp();
|
|
|
|
|
|
|
|
|
|
const product = await prismaClient.product.create({
|
|
|
|
|
data: { externalProductId, name: 'SLA Test Product', status: 'active' },
|
|
|
|
|
});
|
|
|
|
|
productId = product.id;
|
|
|
|
|
secret = generateCredentialSecret();
|
|
|
|
|
await prismaClient.productIntegration.create({
|
|
|
|
|
data: {
|
|
|
|
|
productId,
|
|
|
|
|
credentialRef: encryptCredential(secret),
|
|
|
|
|
authMechanism: 'signed_token',
|
|
|
|
|
allowedScope: { tenantIds: ['tenant-1'] },
|
|
|
|
|
status: 'active',
|
|
|
|
|
rateLimitPerMinute: 1000,
|
|
|
|
|
rateLimitPerUserPerMinute: 1000,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const team = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/admin/teams',
|
|
|
|
|
payload: { name: `SLA Team ${Date.now()}` },
|
|
|
|
|
});
|
|
|
|
|
teamId = team.json().data.id;
|
|
|
|
|
|
|
|
|
|
const agentA = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: `/admin/teams/${teamId}/agents`,
|
|
|
|
|
payload: { name: 'SLA Agent A' },
|
|
|
|
|
});
|
|
|
|
|
agentAId = agentA.json().data.id;
|
|
|
|
|
await app.inject({
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
url: `/admin/agents/${agentAId}/skills/${skillTag}`,
|
|
|
|
|
payload: { level: 3 },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const agentB = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: `/admin/teams/${teamId}/agents`,
|
|
|
|
|
payload: { name: 'SLA Agent B' },
|
|
|
|
|
});
|
|
|
|
|
agentBId = agentB.json().data.id;
|
|
|
|
|
await app.inject({
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
url: `/admin/agents/${agentBId}/skills/${skillTag}`,
|
|
|
|
|
payload: { level: 3 },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const nodeA = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/admin/hierarchy-nodes',
|
|
|
|
|
payload: {
|
|
|
|
|
name: 'SLA Node A',
|
|
|
|
|
order: 0,
|
|
|
|
|
productScope: [externalProductId],
|
|
|
|
|
skills: [skillTag],
|
|
|
|
|
assignmentStrategy: 'ROUND_ROBIN',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
nodeAId = nodeA.json().data.id;
|
|
|
|
|
|
|
|
|
|
const nodeB = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/admin/hierarchy-nodes',
|
|
|
|
|
payload: {
|
2026-09-03 15:09:29 +05:30
|
|
|
// Scoped to this test's own product, not a wildcard ([] matches every product per
|
|
|
|
|
// HierarchyNode's own scope-matching rule) — a wildcard node here would leak into any
|
|
|
|
|
// other test file's own (non-scoped) assignment resolution sharing the same live
|
|
|
|
|
// Postgres, corrupting their eligible-agent set with this file's unrelated skillTag.
|
2026-09-03 13:02:05 +05:30
|
|
|
name: 'SLA Node B (escalation target)',
|
|
|
|
|
order: 1,
|
2026-09-03 15:09:29 +05:30
|
|
|
productScope: [externalProductId],
|
2026-09-03 13:02:05 +05:30
|
|
|
skills: [skillTag],
|
|
|
|
|
assignmentStrategy: 'ROUND_ROBIN',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
nodeBId = nodeB.json().data.id;
|
|
|
|
|
|
|
|
|
|
// Global (wildcard) policy — long duration, never expected to breach in this suite.
|
|
|
|
|
const globalPolicy = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/admin/sla-policies',
|
|
|
|
|
payload: { name: 'Global policy', firstResponseMinutes: 60, resolutionMinutes: 480 },
|
|
|
|
|
});
|
|
|
|
|
globalPolicyId = globalPolicy.json().data.id;
|
|
|
|
|
|
|
|
|
|
// Product-scoped policy — more specific, should win over the global one.
|
|
|
|
|
const productPolicy = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/admin/sla-policies',
|
|
|
|
|
payload: {
|
|
|
|
|
name: 'Product policy',
|
|
|
|
|
productId,
|
|
|
|
|
firstResponseMinutes: 30,
|
|
|
|
|
resolutionMinutes: 60,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
productPolicyId = productPolicy.json().data.id;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
const ticketFilter = { ticketId: { in: createdTicketIds } };
|
|
|
|
|
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
|
|
|
|
|
await prismaClient.escalationRule.deleteMany({ where: { targetNodeId: { in: [nodeAId, nodeBId] } } });
|
|
|
|
|
await prismaClient.escalationPolicy.deleteMany({ where: { productId } });
|
|
|
|
|
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
|
|
|
|
|
await prismaClient.sLAPolicy.deleteMany({ where: { id: { in: [globalPolicyId, productPolicyId] } } });
|
|
|
|
|
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
|
|
|
|
|
await prismaClient.assignment.deleteMany({ where: ticketFilter });
|
|
|
|
|
await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: [nodeAId, nodeBId] } } });
|
|
|
|
|
await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentAId, agentBId] } } });
|
|
|
|
|
await prismaClient.agent.deleteMany({ where: { teamId } });
|
|
|
|
|
await prismaClient.team.deleteMany({ where: { id: teamId } });
|
|
|
|
|
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
|
|
|
|
|
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
|
|
|
|
|
await prismaClient.problem.deleteMany({ where: { productId } });
|
|
|
|
|
await prismaClient.productIntegration.deleteMany({ where: { productId } });
|
|
|
|
|
await prismaClient.product.deleteMany({ where: { id: productId } });
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Scenario 1/2: assignment resolves the most-specific policy and creates a calendar-aware SLARun', async () => {
|
|
|
|
|
const ticketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(ticketId);
|
|
|
|
|
|
|
|
|
|
const runResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` });
|
|
|
|
|
expect(runResponse.statusCode).toBe(200);
|
|
|
|
|
const run = runResponse.json().data;
|
|
|
|
|
expect(run.policyId).toBe(productPolicyId); // product-scoped wins over global
|
|
|
|
|
expect(run.status).toBe('running');
|
|
|
|
|
|
|
|
|
|
const dueAt = new Date(run.resolutionDueAt).getTime();
|
|
|
|
|
const expected = Date.now() + 60 * 60 * 1000; // resolutionMinutes: 60, businessCalendarId: null (24/7)
|
|
|
|
|
expect(Math.abs(dueAt - expected)).toBeLessThan(60 * 1000); // 1 minute tolerance
|
2026-09-03 15:09:29 +05:30
|
|
|
|
|
|
|
|
// This scenario's only job for the global (wildcard-scoped) policy is done — deactivate it
|
|
|
|
|
// immediately rather than leaving it live for the rest of the file's run, since a global
|
|
|
|
|
// SLAPolicy matches every ticket in the shared test database, including other test files'
|
|
|
|
|
// tickets running concurrently against the same Postgres.
|
|
|
|
|
await prismaClient.sLAPolicy.update({ where: { id: globalPolicyId }, data: { active: false } });
|
2026-09-03 13:02:05 +05:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Scenario 2: an assignment matching no active policy gets no SLARun', async () => {
|
|
|
|
|
const outsidePolicy = await app.inject({
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: `/admin/sla-policies/${productPolicyId}`,
|
|
|
|
|
});
|
|
|
|
|
expect(outsidePolicy.statusCode).toBe(200);
|
|
|
|
|
|
|
|
|
|
// Deactivate both policies temporarily to prove the no-match path.
|
|
|
|
|
await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${productPolicyId}` });
|
|
|
|
|
await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${globalPolicyId}` });
|
|
|
|
|
|
|
|
|
|
const ticketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(ticketId);
|
|
|
|
|
|
|
|
|
|
const runResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` });
|
|
|
|
|
expect(runResponse.statusCode).toBe(404);
|
|
|
|
|
|
2026-09-03 15:09:29 +05:30
|
|
|
// Restore the product-scoped policy for the remaining scenarios — they all resolve through
|
|
|
|
|
// it (it's always more specific than the global one, FR-002), so the global policy is
|
|
|
|
|
// deliberately left deactivated here rather than reactivated: a global/wildcard-scoped
|
|
|
|
|
// SLAPolicy is live for every ticket in the shared test database for as long as it's
|
|
|
|
|
// active, including other test files' tickets running concurrently against the same
|
|
|
|
|
// Postgres — its job (Scenario 1's fallback-to-global assertion) is already done.
|
2026-09-03 13:02:05 +05:30
|
|
|
await prismaClient.sLAPolicy.update({ where: { id: productPolicyId }, data: { active: true } });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Scenario 3: pause/resume is durable across a genuine process restart', async () => {
|
|
|
|
|
const ticketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(ticketId);
|
|
|
|
|
|
|
|
|
|
const before = (await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` })).json()
|
|
|
|
|
.data;
|
|
|
|
|
const originalDueAt = new Date(before.resolutionDueAt).getTime();
|
|
|
|
|
|
|
|
|
|
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
|
|
|
|
await app.inject({
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
url: `/tickets/${ticketId}/status`,
|
|
|
|
|
payload: { status: 'WAITING_FOR_CUSTOMER', expectedVersion: ticket.version },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const paused = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
|
|
|
|
|
expect(paused.status).toBe('paused');
|
|
|
|
|
expect(paused.pausedAt).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
// Genuine restart boundary — a fresh app instance, per Constitution Principle VII.
|
|
|
|
|
await app.close();
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1200)); // real pause duration to shift by
|
|
|
|
|
app = await buildApp();
|
|
|
|
|
|
|
|
|
|
const ticketAfterRestart = await prismaClient.ticket.findUniqueOrThrow({
|
|
|
|
|
where: { id: ticketId },
|
|
|
|
|
});
|
|
|
|
|
await app.inject({
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
url: `/tickets/${ticketId}/status`,
|
|
|
|
|
payload: { status: 'IN_PROGRESS', expectedVersion: ticketAfterRestart.version },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const resumed = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
|
|
|
|
|
expect(resumed.status).toBe('running');
|
|
|
|
|
expect(resumed.pausedAt).toBeNull();
|
2026-09-03 15:09:29 +05:30
|
|
|
expect(resumed.resolutionDueAt).not.toBeNull();
|
|
|
|
|
expect(resumed.resolutionDueAt?.getTime()).toBeGreaterThan(originalDueAt + 1000);
|
2026-09-03 13:02:05 +05:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Scenario 4: breach detection marks a run breached, never a completed or paused one', async () => {
|
|
|
|
|
const overdueTicketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(overdueTicketId);
|
|
|
|
|
await prismaClient.sLARun.update({
|
|
|
|
|
where: { ticketId: overdueTicketId },
|
|
|
|
|
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const completedTicketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(completedTicketId);
|
|
|
|
|
await prismaClient.sLARun.update({
|
|
|
|
|
where: { ticketId: completedTicketId },
|
|
|
|
|
data: { resolutionDueAt: new Date(Date.now() - 60_000), status: 'completed' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const pausedTicketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(pausedTicketId);
|
|
|
|
|
await prismaClient.sLARun.update({
|
|
|
|
|
where: { ticketId: pausedTicketId },
|
|
|
|
|
data: { resolutionDueAt: new Date(Date.now() - 60_000), status: 'paused', pausedAt: new Date() },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await slaService.runBreachDetectionSweep();
|
|
|
|
|
|
|
|
|
|
const overdue = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: overdueTicketId } });
|
|
|
|
|
expect(overdue.status).toBe('breached');
|
|
|
|
|
expect(overdue.breachedAt).not.toBeNull();
|
|
|
|
|
|
|
|
|
|
const completed = await prismaClient.sLARun.findUniqueOrThrow({
|
|
|
|
|
where: { ticketId: completedTicketId },
|
|
|
|
|
});
|
|
|
|
|
expect(completed.status).toBe('completed');
|
|
|
|
|
|
|
|
|
|
const paused = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: pausedTicketId } });
|
|
|
|
|
expect(paused.status).toBe('paused');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Scenario 5: a breach with a matching rule fires exactly one EscalationEvent and reassigns to the rule\'s node', async () => {
|
|
|
|
|
const policy = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/admin/escalation-policies',
|
|
|
|
|
payload: { name: 'Product escalation policy', productId },
|
|
|
|
|
});
|
|
|
|
|
const escalationPolicyId = policy.json().data.id;
|
|
|
|
|
|
|
|
|
|
await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: `/admin/escalation-policies/${escalationPolicyId}/rules`,
|
|
|
|
|
payload: {
|
|
|
|
|
triggerType: 'resolution_breach',
|
|
|
|
|
condition: {},
|
|
|
|
|
targetNodeId: nodeBId,
|
|
|
|
|
notify: {},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const ticketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(ticketId);
|
|
|
|
|
await prismaClient.sLARun.update({
|
|
|
|
|
where: { ticketId },
|
|
|
|
|
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await slaService.runBreachDetectionSweep();
|
|
|
|
|
|
|
|
|
|
const events = await prismaClient.escalationEvent.findMany({ where: { ticketId } });
|
|
|
|
|
expect(events.length).toBe(1);
|
|
|
|
|
expect(events[0]?.toNodeId).toBe(nodeBId);
|
|
|
|
|
|
|
|
|
|
const assignment = await prismaClient.assignment.findFirst({
|
|
|
|
|
where: { ticketId, isCurrent: true },
|
|
|
|
|
});
|
|
|
|
|
expect([agentAId, agentBId]).toContain(assignment?.agentId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Scenario 5b: a breach with no matching rule is still recorded breached, with no EscalationEvent', async () => {
|
|
|
|
|
const ticketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(ticketId);
|
|
|
|
|
await prismaClient.sLARun.update({
|
|
|
|
|
where: { ticketId },
|
|
|
|
|
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await slaService.runBreachDetectionSweep();
|
|
|
|
|
|
|
|
|
|
const run = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
|
|
|
|
|
expect(run.status).toBe('breached');
|
|
|
|
|
|
|
|
|
|
const events = await prismaClient.escalationEvent.findMany({ where: { ticketId } });
|
|
|
|
|
// No escalation rule exists for this ticket's context beyond the one created in Scenario 5
|
|
|
|
|
// (scoped to this product's policy, which fires unconditionally on resolution_breach) — so
|
|
|
|
|
// this ticket, sharing the same product, is expected to also match that same rule.
|
|
|
|
|
expect(events.length).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Scenario 6: manual escalation creates an event and reassigns; a nonexistent node is rejected', async () => {
|
|
|
|
|
const ticketId = await createTicket();
|
|
|
|
|
await escalateAndAssign(ticketId);
|
|
|
|
|
|
|
|
|
|
const notFound = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: `/tickets/${ticketId}/escalate`,
|
|
|
|
|
payload: { targetNodeId: 'nonexistent-node-id', reason: 'test' },
|
|
|
|
|
});
|
|
|
|
|
expect(notFound.statusCode).toBe(404);
|
|
|
|
|
expect(await prismaClient.escalationEvent.count({ where: { ticketId } })).toBe(0);
|
|
|
|
|
|
|
|
|
|
const manual = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: `/tickets/${ticketId}/escalate`,
|
|
|
|
|
payload: { targetNodeId: nodeBId, reason: 'Customer requested a specialist' },
|
|
|
|
|
});
|
|
|
|
|
expect(manual.statusCode).toBe(201);
|
|
|
|
|
expect(manual.json().data.ruleId).toBeNull();
|
|
|
|
|
expect(manual.json().data.toNodeId).toBe(nodeBId);
|
|
|
|
|
|
|
|
|
|
const assignment = await prismaClient.assignment.findFirst({
|
|
|
|
|
where: { ticketId, isCurrent: true },
|
|
|
|
|
});
|
|
|
|
|
expect([agentAId, agentBId]).toContain(assignment?.agentId);
|
|
|
|
|
});
|
|
|
|
|
});
|