Шаг A3 (из 1.6) плана production-готовности: - package.json: скрипт "test": "vitest run" - pr-check.yml: шаг Tests после type check - server/utils/userActivity.ts: try/catch в trackUserActivity — синхронная ошибка трекинга активности больше не срывает authenticateToken (ложный 403 на первом запросе пользователя за минуту) - tests/auto-transitions.test.ts: 3 устаревших теста обновлены под намеренное поведение (forward-only position, синк assignee, аудит вместо системного сообщения) 45/45 тестов зелёные, npm run check чисто.
452 lines
19 KiB
TypeScript
452 lines
19 KiB
TypeScript
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||
|
||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||
|
||
const mockStorage = vi.hoisted(() => ({
|
||
getTask: vi.fn(),
|
||
getFormStatuses: vi.fn(),
|
||
getFormFields: vi.fn(),
|
||
getTaskFieldValues: vi.fn(),
|
||
getUsersByOrganization: vi.fn().mockResolvedValue([]),
|
||
getRoles: vi.fn().mockResolvedValue([]),
|
||
getDataTableFull: vi.fn().mockResolvedValue(undefined),
|
||
getStatusTransitions: vi.fn().mockResolvedValue([]),
|
||
getRoleMembersByRole: vi.fn().mockResolvedValue([]),
|
||
updateTask: vi.fn(),
|
||
addTaskAssignee: vi.fn().mockResolvedValue(undefined),
|
||
addTaskAuditLog: vi.fn().mockResolvedValue(undefined),
|
||
getForm: vi.fn().mockResolvedValue(null),
|
||
}));
|
||
|
||
vi.mock('../server/db', () => ({
|
||
db: {},
|
||
}));
|
||
|
||
vi.mock('../server/storage', () => ({ storage: mockStorage }));
|
||
|
||
vi.mock('../server/services/notification.service', () => ({
|
||
notificationService: {
|
||
processEvent: vi.fn().mockResolvedValue([]),
|
||
},
|
||
EVENT_TYPES: { TASK_ASSIGNED: 'task.assigned', TASK_STATUS_CHANGED: 'task.status_changed' },
|
||
}));
|
||
|
||
vi.mock('../server/services/webhook.service', () => ({
|
||
webhookService: {
|
||
dispatchStatusChange: vi.fn().mockResolvedValue(undefined),
|
||
dispatchEvent: vi.fn().mockResolvedValue(undefined),
|
||
},
|
||
}));
|
||
|
||
vi.mock('../server/utils/cache', () => ({
|
||
tasksMinimalCache: { invalidatePrefix: vi.fn() },
|
||
}));
|
||
|
||
vi.mock('../server/routes/shared', () => ({
|
||
eventBus: { publishEvent: vi.fn() },
|
||
resolveTaskFieldTitles: vi.fn().mockResolvedValue(new Map()),
|
||
formatFieldValueForTitle: vi.fn().mockReturnValue(''),
|
||
buildSystemFieldValues: vi.fn().mockResolvedValue(new Map()),
|
||
}));
|
||
|
||
vi.mock('../server/utils/pushTaskUpdated', () => ({
|
||
pushTaskUpdated: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
vi.mock('../server/routes/task-helpers', () => ({
|
||
indexTaskAsync: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
vi.mock('../server/services/delegation.service', () => ({
|
||
ensureReviewAssignees: vi.fn().mockResolvedValue(undefined),
|
||
cleanupStaleDelegates: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||
|
||
import { evaluateAutoTransitions } from '../server/utils/auto-transitions';
|
||
import { notificationService } from '../server/services/notification.service';
|
||
import { pushTaskUpdated } from '../server/utils/pushTaskUpdated';
|
||
|
||
function makeTask(overrides: Partial<any> = {}) {
|
||
return {
|
||
id: 1,
|
||
title: 'Test Task',
|
||
formId: 1,
|
||
currentStatusId: 10,
|
||
assignedTo: null,
|
||
organizationId: 1,
|
||
createdBy: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function makeStatus(overrides: Partial<any> = {}) {
|
||
return {
|
||
id: 10,
|
||
name: 'Initial',
|
||
color: '#e5e7eb',
|
||
position: 0,
|
||
isInitial: true,
|
||
isFinal: false,
|
||
entryConditions: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('evaluateAutoTransitions', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('should transition to matching status on task creation', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0, isInitial: true }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', value: 'Автопролонгация' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'Автопролонгация' },
|
||
]);
|
||
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20 }));
|
||
|
||
const result = await evaluateAutoTransitions(1, 1, { triggeredBy: 1 });
|
||
|
||
expect(result.changed).toBe(true);
|
||
expect(result.visitedStatusIds).toContain(10);
|
||
expect(result.visitedStatusIds).toContain(20);
|
||
expect(mockStorage.updateTask).toHaveBeenCalledWith(1, 1, expect.objectContaining({ currentStatusId: 20 }));
|
||
});
|
||
|
||
it('should stay in initial status when no conditions match', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0, isInitial: true }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', value: 'Автопролонгация' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'Другое значение' },
|
||
]);
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(false);
|
||
expect(result.task.currentStatusId).toBe(10);
|
||
expect(mockStorage.updateTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should transition when entryCondition uses empty operator on empty field', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0, isInitial: true }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', operator: 'empty' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: '' },
|
||
]);
|
||
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20 }));
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(true);
|
||
expect(result.task.currentStatusId).toBe(20);
|
||
});
|
||
|
||
it('should NOT transition when entryCondition uses empty operator on filled field', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0, isInitial: true }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', operator: 'empty' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'has value' },
|
||
]);
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(false);
|
||
expect(result.task.currentStatusId).toBe(10);
|
||
});
|
||
|
||
it('should transition when entryCondition uses filled operator on filled field', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0, isInitial: true }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', operator: 'filled' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'has value' },
|
||
]);
|
||
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20 }));
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(true);
|
||
expect(result.task.currentStatusId).toBe(20);
|
||
});
|
||
|
||
it('should NOT transition when entryCondition uses filled operator on empty field', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0, isInitial: true }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', operator: 'filled' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: '' },
|
||
]);
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(false);
|
||
expect(result.task.currentStatusId).toBe(10);
|
||
});
|
||
|
||
it('should respect visited set to prevent back-and-forth loops', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'A', position: 0 }),
|
||
makeStatus({ id: 20, name: 'B', position: 1, entryConditions: [{ fieldCode: 'field_4', value: 'x' }] }),
|
||
makeStatus({ id: 30, name: 'C', position: 2, entryConditions: [{ fieldCode: 'field_4', value: 'x' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'x' },
|
||
]);
|
||
|
||
mockStorage.updateTask.mockImplementation((id, org, data) =>
|
||
Promise.resolve(makeTask({ currentStatusId: data.currentStatusId as number }))
|
||
);
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(true);
|
||
// 10→20→30, дальше цепочка обрывается: автопереходы двигают задачу
|
||
// только ВПЕРЁД по position (auto-transitions.ts), поэтому отката 30→20 нет
|
||
expect(result.visitedStatusIds).toEqual([10, 20, 30]);
|
||
expect(mockStorage.updateTask.mock.calls.length).toBe(2);
|
||
});
|
||
|
||
it('should stop at max depth to prevent infinite loops', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'A', position: 0, entryConditions: [{ fieldCode: 'field_4', value: 'x' }] }),
|
||
makeStatus({ id: 20, name: 'B', position: 1, entryConditions: [{ fieldCode: 'field_4', value: 'x' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'x' },
|
||
]);
|
||
|
||
mockStorage.updateTask.mockImplementation((id, org, data) =>
|
||
Promise.resolve(makeTask({ currentStatusId: data.currentStatusId as number }))
|
||
);
|
||
|
||
const result = await evaluateAutoTransitions(1, 1, { maxDepth: 3 });
|
||
|
||
expect(result.changed).toBe(true);
|
||
// Правило «только вперёд» останавливает цепочку на B (10→20), maxDepth не достигается
|
||
expect(mockStorage.updateTask.mock.calls.length).toBeLessThanOrEqual(3);
|
||
});
|
||
|
||
it('should resolve assignee from matching transition', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10, assignedTo: null }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0 }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', value: 'yes' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'yes' },
|
||
]);
|
||
mockStorage.getStatusTransitions.mockResolvedValue([
|
||
{ id: 1, fromStatusId: 10, toStatusId: 20, assigneeUserId: 42 },
|
||
]);
|
||
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20, assignedTo: 42 }));
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(true);
|
||
expect(mockStorage.updateTask).toHaveBeenCalledWith(1, 1, expect.objectContaining({ assignedTo: 42 }));
|
||
});
|
||
|
||
it('should record "Автопереход" as status changer in audit log', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0 }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', value: 'yes' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'yes' },
|
||
]);
|
||
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20 }));
|
||
|
||
await evaluateAutoTransitions(1, 1, { triggeredBy: 1 });
|
||
|
||
expect(mockStorage.addTaskAuditLog).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
changedBy: null,
|
||
changedByName: 'Автопереход',
|
||
metadata: { autoTransition: true },
|
||
})
|
||
);
|
||
});
|
||
|
||
it('should NOT add triggeredBy as task assignee on auto-transition', async () => {
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10, assignedTo: null }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0 }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', value: 'yes' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'yes' },
|
||
]);
|
||
// Явно пустой список переходов: правил назначения нет, поэтому
|
||
// triggeredBy не должен попадать в исполнители задачи
|
||
mockStorage.getStatusTransitions.mockResolvedValue([]);
|
||
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20 }));
|
||
|
||
await evaluateAutoTransitions(1, 1, { triggeredBy: 1 });
|
||
|
||
expect(mockStorage.addTaskAssignee).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should sync resolved assignee into task_assignees on auto-transition', async () => {
|
||
// Намеренное поведение (auto-transitions.ts): основной исполнитель
|
||
// синкается в task_assignees, чтобы TaskDetail мог его отобразить
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10, assignedTo: null }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0 }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [{ fieldCode: 'field_4', value: 'yes' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'yes' },
|
||
]);
|
||
mockStorage.getStatusTransitions.mockResolvedValue([
|
||
{ id: 1, fromStatusId: 10, toStatusId: 20, assigneeUserId: 42 },
|
||
]);
|
||
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20, assignedTo: 42 }));
|
||
|
||
await evaluateAutoTransitions(1, 1, { triggeredBy: 1 });
|
||
|
||
expect(mockStorage.addTaskAssignee).toHaveBeenCalledWith(1, 42, 1);
|
||
});
|
||
|
||
it('should use AND logic for entryConditions and not bounce back', async () => {
|
||
// Open TO: _assignee empty AND field_4 contains "ТО"
|
||
// Assigned: _assignee filled
|
||
// Task already has assignee (simulating update after user assigned someone)
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10, assignedTo: 5 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Open TO', position: 0, entryConditions: [
|
||
{ fieldCode: '_assignee', operator: 'empty' },
|
||
{ fieldCode: 'field_4', value: 'ТО' },
|
||
] }),
|
||
makeStatus({ id: 20, name: 'Assigned', position: 1, entryConditions: [{ fieldCode: '_assignee', operator: 'filled' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'ТО' },
|
||
]);
|
||
mockStorage.updateTask.mockImplementation((id, org, data) =>
|
||
Promise.resolve(makeTask({ currentStatusId: data.currentStatusId as number, assignedTo: 5 }))
|
||
);
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(true);
|
||
expect(result.task.currentStatusId).toBe(20);
|
||
// Should NOT bounce back to 10 because Open TO requires _assignee empty (AND logic)
|
||
// With old OR logic it would bounce back because field_4 still contains "ТО"
|
||
expect(result.visitedStatusIds).toEqual([10, 20]);
|
||
});
|
||
|
||
it('should require ALL entryConditions to match (AND)', async () => {
|
||
// Status requires field_4 = "yes" AND _assignee filled
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10, assignedTo: 5 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0 }),
|
||
makeStatus({ id: 20, name: 'Auto', position: 1, entryConditions: [
|
||
{ fieldCode: 'field_4', value: 'yes' },
|
||
{ fieldCode: '_assignee', operator: 'filled' },
|
||
] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'no' }, // does NOT match first rule
|
||
]);
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
// AND logic: first rule fails → whole condition fails → no transition
|
||
expect(result.changed).toBe(false);
|
||
expect(result.task.currentStatusId).toBe(10);
|
||
});
|
||
|
||
it('should send TASK_ASSIGNED notification when auto-transition changes assignee', async () => {
|
||
// assignedTo=5 means _assignee is filled → entryConditions match → transition fires
|
||
mockStorage.getTask.mockResolvedValue(makeTask({ currentStatusId: 10, assignedTo: 5 }));
|
||
mockStorage.getFormStatuses.mockResolvedValue([
|
||
makeStatus({ id: 10, name: 'Initial', position: 0 }),
|
||
makeStatus({ id: 20, name: 'Assigned', position: 1, entryConditions: [{ fieldCode: '_assignee', operator: 'filled' }] }),
|
||
]);
|
||
mockStorage.getFormFields.mockResolvedValue([
|
||
{ id: 4, code: 'field_4', name: 'Field 4', type: 'text' },
|
||
]);
|
||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||
{ fieldId: 4, value: 'yes' },
|
||
]);
|
||
mockStorage.getStatusTransitions.mockResolvedValue([
|
||
{ id: 1, fromStatusId: 10, toStatusId: 20, assigneeUserId: 42 },
|
||
]);
|
||
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20, assignedTo: 42 }));
|
||
|
||
const result = await evaluateAutoTransitions(1, 1);
|
||
|
||
expect(result.changed).toBe(true);
|
||
expect(notificationService.processEvent).toHaveBeenCalled();
|
||
expect(notificationService.processEvent).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
type: 'task.assigned',
|
||
payload: expect.objectContaining({ assignedTo: 42 }),
|
||
})
|
||
);
|
||
});
|
||
});
|