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 = {}) { return { id: 1, title: 'Test Task', formId: 1, currentStatusId: 10, assignedTo: null, organizationId: 1, createdBy: 1, ...overrides, }; } function makeStatus(overrides: Partial = {}) { 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 }), }) ); }); });