Files
iistwin/tests/auto-transitions.test.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

442 lines
18 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),
createTaskMessage: 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),
}));
// ─── 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, then 30→20 but 20 already visited → stop
expect(result.visitedStatusIds).toEqual([10, 20, 30]);
expect(mockStorage.updateTask.mock.calls.length).toBe(3);
});
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);
// Should stop after maxDepth transitions (3 updates: A→B→A→B, but maxDepth=3 means 3 iterations)
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' },
]);
mockStorage.updateTask.mockResolvedValue(makeTask({ currentStatusId: 20 }));
await evaluateAutoTransitions(1, 1, { triggeredBy: 1 });
expect(mockStorage.addTaskAssignee).not.toHaveBeenCalled();
});
it('should keep authorId=0 in system message for auto-transition', 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.createTaskMessage).toHaveBeenCalledWith(
expect.objectContaining({ authorId: 0 }),
expect.any(Number)
);
});
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 }),
})
);
});
});