Files
iistwin/tests/auto-transitions.test.ts
Ильяс Султанов b3d7923b23 test(ci): скрипт test, тесты в pr-check, фикс sporadic 403 в trackUserActivity
Шаг 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 чисто.
2026-09-07 20:28:44 +03:00

452 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }),
})
);
});
});