fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
441
tests/auto-transitions.test.ts
Normal file
441
tests/auto-transitions.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
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 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
331
tests/bookmarks.test.ts
Normal file
331
tests/bookmarks.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
getUserWithOrganization: vi.fn(),
|
||||
createBookmark: vi.fn(),
|
||||
createBookmarkFolder: vi.fn(),
|
||||
getBookmarkFolder: vi.fn(),
|
||||
getBookmarksByOrganization: vi.fn(),
|
||||
getBookmarksByFolder: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
getFormStatuses: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../server/db', () => ({
|
||||
db: {},
|
||||
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
|
||||
withTenant: (_orgId: number, fn: () => unknown) => fn(),
|
||||
openTenantCtx: vi.fn().mockResolvedValue({
|
||||
run: (fn: () => void) => fn(),
|
||||
release: vi.fn(),
|
||||
}),
|
||||
openSuperAdminCtx: vi.fn().mockResolvedValue(undefined),
|
||||
_tenantCtx: { getStore: vi.fn().mockReturnValue(null) },
|
||||
}));
|
||||
|
||||
vi.mock('../server/storage', () => ({ storage: mockStorage }));
|
||||
|
||||
vi.mock('../server/services/notification.service', () => ({
|
||||
notificationService: {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
processEvent: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
EVENT_TYPES: {},
|
||||
}));
|
||||
|
||||
vi.mock('../server/services/webhook.service', () => ({
|
||||
webhookService: {
|
||||
dispatchEvent: vi.fn().mockResolvedValue(undefined),
|
||||
processWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../server/utils/webhook', () => ({
|
||||
sendWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../server/utils/s3', () => ({
|
||||
isS3Enabled: false,
|
||||
ensureS3Bucket: vi.fn().mockResolvedValue(undefined),
|
||||
streamFromS3: vi.fn().mockResolvedValue(null),
|
||||
deleteFromS3: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../server/utils/audit', () => ({
|
||||
logAudit: vi.fn().mockResolvedValue(undefined),
|
||||
getClientIp: vi.fn().mockReturnValue('127.0.0.1'),
|
||||
}));
|
||||
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import chatRouter from '../server/routes/chat.routes';
|
||||
|
||||
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET as string;
|
||||
|
||||
function makeValidToken(userId: number, organizationId: number): string {
|
||||
return jwt.sign(
|
||||
{ userId, organizationId, role: 'user' },
|
||||
ACCESS_SECRET,
|
||||
{ issuer: 'workflow-system', audience: 'workflow-users', expiresIn: '1h' },
|
||||
);
|
||||
}
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(chatRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
const TEST_USER = {
|
||||
id: 42,
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
role: 'user',
|
||||
isActive: true,
|
||||
organizationId: 7,
|
||||
organization: { id: 7, isActive: true, billingBlocked: false },
|
||||
};
|
||||
|
||||
describe('POST /api/bookmarks — auth guard', () => {
|
||||
const app = buildApp();
|
||||
|
||||
const validBookmarkBody = {
|
||||
name: 'My Bookmark',
|
||||
type: 'dashboard',
|
||||
};
|
||||
|
||||
it('returns 401 when Authorization header is absent', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.send(validBookmarkBody);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({ error: expect.any(String) });
|
||||
});
|
||||
|
||||
it('returns 403 when token is invalid / tampered', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Authorization', 'Bearer not-a-real-token')
|
||||
.send(validBookmarkBody);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 when token is signed with the wrong secret', async () => {
|
||||
const badToken = jwt.sign(
|
||||
{ userId: 1, organizationId: 1, role: 'user' },
|
||||
'completely-wrong-secret',
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Authorization', `Bearer ${badToken}`)
|
||||
.send(validBookmarkBody);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 401 and does not write to storage when token is valid but user does not exist', async () => {
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue(undefined);
|
||||
mockStorage.createBookmark.mockClear();
|
||||
|
||||
const token = makeValidToken(99, 1);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(validBookmarkBody);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockStorage.createBookmark).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 and does not write to storage when user account is inactive', async () => {
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue({
|
||||
...TEST_USER,
|
||||
isActive: false,
|
||||
});
|
||||
mockStorage.createBookmark.mockClear();
|
||||
|
||||
const token = makeValidToken(TEST_USER.id, TEST_USER.organizationId);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(validBookmarkBody);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockStorage.createBookmark).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with a valid token', () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue(TEST_USER);
|
||||
mockStorage.createBookmark.mockResolvedValue({
|
||||
id: 1,
|
||||
...validBookmarkBody,
|
||||
userId: TEST_USER.id,
|
||||
organizationId: TEST_USER.organizationId,
|
||||
createdBy: TEST_USER.id,
|
||||
position: 0,
|
||||
folderId: null,
|
||||
targetId: null,
|
||||
formId: null,
|
||||
url: null,
|
||||
icon: null,
|
||||
viewConfig: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 201 and the created bookmark contains the authenticated user\'s id', async () => {
|
||||
const token = makeValidToken(TEST_USER.id, TEST_USER.organizationId);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(validBookmarkBody);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.bookmark.userId).toBe(TEST_USER.id);
|
||||
});
|
||||
|
||||
it('passes the authenticated user\'s id to storage.createBookmark', async () => {
|
||||
const token = makeValidToken(TEST_USER.id, TEST_USER.organizationId);
|
||||
|
||||
await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(validBookmarkBody);
|
||||
|
||||
expect(mockStorage.createBookmark).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: TEST_USER.id }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/bookmark-folders — auth guard', () => {
|
||||
const app = buildApp();
|
||||
|
||||
const validFolderBody = {
|
||||
name: 'My Folder',
|
||||
};
|
||||
|
||||
it('returns 401 when Authorization header is absent', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/bookmark-folders')
|
||||
.send(validFolderBody);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({ error: expect.any(String) });
|
||||
});
|
||||
|
||||
it('returns 403 when token is invalid / tampered', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/bookmark-folders')
|
||||
.set('Authorization', 'Bearer garbage-token')
|
||||
.send(validFolderBody);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 when token is signed with the wrong secret', async () => {
|
||||
const badToken = jwt.sign(
|
||||
{ userId: 1, organizationId: 1, role: 'user' },
|
||||
'wrong-secret',
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/bookmark-folders')
|
||||
.set('Authorization', `Bearer ${badToken}`)
|
||||
.send(validFolderBody);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 401 and does not write to storage when token is valid but user does not exist', async () => {
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue(undefined);
|
||||
mockStorage.createBookmarkFolder.mockClear();
|
||||
|
||||
const token = makeValidToken(99, 1);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/bookmark-folders')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(validFolderBody);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockStorage.createBookmarkFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 and does not write to storage when user account is inactive', async () => {
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue({
|
||||
...TEST_USER,
|
||||
isActive: false,
|
||||
});
|
||||
mockStorage.createBookmarkFolder.mockClear();
|
||||
|
||||
const token = makeValidToken(TEST_USER.id, TEST_USER.organizationId);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/bookmark-folders')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(validFolderBody);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockStorage.createBookmarkFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with a valid token', () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue(TEST_USER);
|
||||
mockStorage.createBookmarkFolder.mockResolvedValue({
|
||||
id: 10,
|
||||
...validFolderBody,
|
||||
userId: TEST_USER.id,
|
||||
organizationId: TEST_USER.organizationId,
|
||||
createdBy: TEST_USER.id,
|
||||
parentId: null,
|
||||
position: 0,
|
||||
isExpanded: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 201 and the created folder contains the authenticated user\'s id', async () => {
|
||||
const token = makeValidToken(TEST_USER.id, TEST_USER.organizationId);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/bookmark-folders')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(validFolderBody);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.folder.userId).toBe(TEST_USER.id);
|
||||
});
|
||||
|
||||
it('passes the authenticated user\'s id to storage.createBookmarkFolder', async () => {
|
||||
const token = makeValidToken(TEST_USER.id, TEST_USER.organizationId);
|
||||
|
||||
await request(app)
|
||||
.post('/api/bookmark-folders')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(validFolderBody);
|
||||
|
||||
expect(mockStorage.createBookmarkFolder).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: TEST_USER.id }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
4
tests/setup.ts
Normal file
4
tests/setup.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
process.env.JWT_ACCESS_SECRET = 'test-access-secret-for-vitest-32chars!!';
|
||||
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret-for-vitest-32c!!';
|
||||
process.env.JWT_SUPERADMIN_SECRET = 'test-superadmin-secret-vitest-32ch!';
|
||||
process.env.NODE_ENV = 'test';
|
||||
660
tests/task-transitions.test.ts
Normal file
660
tests/task-transitions.test.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockStorage = vi.hoisted(() => ({
|
||||
getUserWithOrganization: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getStatusTransitions: vi.fn(),
|
||||
getTaskFieldValues: vi.fn(),
|
||||
getFormFields: vi.fn(),
|
||||
getDataTableRows: vi.fn(),
|
||||
getDataTableRow: vi.fn(),
|
||||
getRoleMembersByRole: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
getFormStatuses: vi.fn(),
|
||||
addTaskRole: vi.fn(),
|
||||
addTaskAssignee: vi.fn(),
|
||||
recordTransitionAccess: vi.fn(),
|
||||
addTaskAuditLog: vi.fn(),
|
||||
createTaskMessage: vi.fn(),
|
||||
getRole: vi.fn(),
|
||||
getUsersByOrganization: vi.fn().mockResolvedValue([]),
|
||||
getRoles: vi.fn().mockResolvedValue([]),
|
||||
getDataTableFull: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../server/db', () => ({
|
||||
db: {},
|
||||
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
|
||||
withTenant: (_orgId: number, fn: () => unknown) => fn(),
|
||||
withSuperAdmin: (fn: () => unknown) => fn(),
|
||||
openTenantCtx: vi.fn().mockResolvedValue({
|
||||
run: (fn: () => void) => fn(),
|
||||
release: vi.fn(),
|
||||
}),
|
||||
openSuperAdminCtx: vi.fn().mockResolvedValue(undefined),
|
||||
_tenantCtx: { getStore: vi.fn().mockReturnValue(null) },
|
||||
}));
|
||||
|
||||
vi.mock('../server/storage', () => ({ storage: mockStorage }));
|
||||
|
||||
vi.mock('../server/services/notification.service', () => ({
|
||||
notificationService: {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
processEvent: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
EVENT_TYPES: {},
|
||||
}));
|
||||
|
||||
vi.mock('../server/services/webhook.service', () => ({
|
||||
webhookService: {
|
||||
dispatchStatusChange: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchEvent: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../server/utils/s3', () => ({
|
||||
isS3Enabled: false,
|
||||
ensureS3Bucket: vi.fn().mockResolvedValue(undefined),
|
||||
streamFromS3: vi.fn().mockResolvedValue(null),
|
||||
deleteFromS3: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../server/utils/cache', () => ({
|
||||
tasksMinimalCache: { invalidatePrefix: vi.fn() },
|
||||
TtlCache: vi.fn().mockImplementation(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
invalidatePrefix: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../server/routes/shared', () => ({
|
||||
eventBus: { publishEvent: vi.fn() },
|
||||
resolveTaskFieldTitles: 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),
|
||||
parseOfflineTimestamp: vi.fn().mockReturnValue(undefined),
|
||||
}));
|
||||
|
||||
// ─── App setup ────────────────────────────────────────────────────────────────
|
||||
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { registerTaskTransitionRoutes } from '../server/routes/task-transitions.routes';
|
||||
|
||||
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET as string;
|
||||
|
||||
function makeToken(userId: number, organizationId: number): string {
|
||||
return jwt.sign(
|
||||
{ userId, organizationId, role: 'user' },
|
||||
ACCESS_SECRET,
|
||||
{ issuer: 'workflow-system', audience: 'workflow-users', expiresIn: '1h' },
|
||||
);
|
||||
}
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
registerTaskTransitionRoutes(router);
|
||||
app.use(router);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Shared fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
const ORG_ID = 10;
|
||||
const USER_ID = 42;
|
||||
|
||||
const TEST_USER = {
|
||||
id: USER_ID,
|
||||
email: 'alice@example.com',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Smith',
|
||||
role: 'user',
|
||||
isActive: true,
|
||||
organizationId: ORG_ID,
|
||||
organization: { id: ORG_ID, isActive: true, billingBlocked: false },
|
||||
};
|
||||
|
||||
const BASE_TASK = {
|
||||
id: 1,
|
||||
formId: 5,
|
||||
organizationId: ORG_ID,
|
||||
currentStatusId: 100,
|
||||
title: 'Test Task',
|
||||
isCompleted: false,
|
||||
};
|
||||
|
||||
const BASE_STATUSES = [
|
||||
{ id: 100, name: 'In Review', isFinal: false },
|
||||
{ id: 200, name: 'Approved', isFinal: false },
|
||||
];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function resetStorageDefaults() {
|
||||
mockStorage.getUserWithOrganization.mockResolvedValue(TEST_USER);
|
||||
mockStorage.getTask.mockResolvedValue(BASE_TASK);
|
||||
mockStorage.getFormStatuses.mockResolvedValue(BASE_STATUSES);
|
||||
mockStorage.updateTask.mockResolvedValue({ ...BASE_TASK, currentStatusId: 200 });
|
||||
mockStorage.addTaskRole.mockResolvedValue(undefined);
|
||||
mockStorage.addTaskAssignee.mockResolvedValue(undefined);
|
||||
mockStorage.recordTransitionAccess.mockResolvedValue(undefined);
|
||||
mockStorage.addTaskAuditLog.mockResolvedValue(undefined);
|
||||
mockStorage.createTaskMessage.mockResolvedValue(undefined);
|
||||
mockStorage.getRole.mockResolvedValue(null);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/tasks/:id/transition — assigneeConditions', () => {
|
||||
const app = buildApp();
|
||||
const token = makeToken(USER_ID, ORG_ID);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStorageDefaults();
|
||||
});
|
||||
|
||||
// ── Type B: справочник lookup ────────────────────────────────────────────────
|
||||
|
||||
describe('Type B — справочник lookup', () => {
|
||||
const FIELD_ID = 20;
|
||||
const TABLE_ID = 99;
|
||||
const TRANSITION = {
|
||||
id: 1,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: null,
|
||||
assigneeConditions: {
|
||||
rules: [
|
||||
{
|
||||
fieldCode: 'department',
|
||||
tableId: TABLE_ID,
|
||||
tableKeyColumnIndex: 0,
|
||||
tableUserColumnIndex: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
conditions: null,
|
||||
};
|
||||
|
||||
it('authorizes (200) when the lookup row resolves to the current user', async () => {
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: FIELD_ID, value: 'Engineering' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: FIELD_ID, code: 'department', options: {} },
|
||||
]);
|
||||
mockStorage.getDataTableRows.mockResolvedValue([
|
||||
{ id: 1, values: ['Engineering', USER_ID] },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 403 when the lookup row resolves to a different user', async () => {
|
||||
const OTHER_USER_ID = 999;
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: FIELD_ID, value: 'Engineering' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: FIELD_ID, code: 'department', options: {} },
|
||||
]);
|
||||
mockStorage.getDataTableRows.mockResolvedValue([
|
||||
{ id: 1, values: ['Engineering', OTHER_USER_ID] },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 when the task field is empty (no lookup key)', async () => {
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: FIELD_ID, code: 'department', options: {} },
|
||||
]);
|
||||
mockStorage.getDataTableRows.mockResolvedValue([
|
||||
{ id: 1, values: ['Engineering', USER_ID] },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockStorage.getDataTableRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when no matching row exists in the table', async () => {
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: FIELD_ID, value: 'Marketing' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: FIELD_ID, code: 'department', options: {} },
|
||||
]);
|
||||
mockStorage.getDataTableRows.mockResolvedValue([
|
||||
{ id: 1, values: ['Engineering', USER_ID] },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('handles numeric field values by coercing to string before comparison', async () => {
|
||||
const NUMERIC_TRANSITION = {
|
||||
...TRANSITION,
|
||||
assigneeConditions: {
|
||||
rules: [
|
||||
{
|
||||
fieldCode: 'dept_id',
|
||||
value: 7,
|
||||
tableId: TABLE_ID,
|
||||
tableKeyColumnIndex: 0,
|
||||
tableUserColumnIndex: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([NUMERIC_TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: FIELD_ID, value: 7 },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: FIELD_ID, code: 'dept_id', options: {} },
|
||||
]);
|
||||
mockStorage.getDataTableRows.mockResolvedValue([
|
||||
{ id: 1, values: ['7', USER_ID] },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type C: task field is table-type (row reference) ────────────────────────
|
||||
|
||||
describe('Type C — task field stores a row ID', () => {
|
||||
const FIELD_ID = 30;
|
||||
const TABLE_ID = 88;
|
||||
const ROW_ID = 5;
|
||||
const TRANSITION = {
|
||||
id: 2,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: null,
|
||||
assigneeConditions: {
|
||||
rules: [
|
||||
{
|
||||
fieldCode: 'responsible_row',
|
||||
tableUserColumnIndex: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
conditions: null,
|
||||
};
|
||||
const FIELD_WITH_TABLE = {
|
||||
id: FIELD_ID,
|
||||
code: 'responsible_row',
|
||||
options: { tableId: TABLE_ID },
|
||||
};
|
||||
|
||||
it('authorizes (200) when the referenced row resolves to the current user', async () => {
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: FIELD_ID, value: String(ROW_ID) },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([FIELD_WITH_TABLE]);
|
||||
mockStorage.getDataTableRow.mockResolvedValue({
|
||||
id: ROW_ID,
|
||||
values: ['Alice', 'Smith', USER_ID],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 2 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockStorage.getDataTableRow).toHaveBeenCalledWith(ROW_ID, TABLE_ID, ORG_ID);
|
||||
});
|
||||
|
||||
it('returns 403 when the referenced row resolves to a different user', async () => {
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: FIELD_ID, value: String(ROW_ID) },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([FIELD_WITH_TABLE]);
|
||||
mockStorage.getDataTableRow.mockResolvedValue({
|
||||
id: ROW_ID,
|
||||
values: ['Bob', 'Jones', 777],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 2 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 when the task field is empty (no row ID)', async () => {
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([]);
|
||||
mockStorage.getFormFields.mockResolvedValue([FIELD_WITH_TABLE]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 2 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockStorage.getDataTableRow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when the referenced row no longer exists in the table', async () => {
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: FIELD_ID, value: '999' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([FIELD_WITH_TABLE]);
|
||||
mockStorage.getDataTableRow.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 2 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Direct assigneeUserId check ──────────────────────────────────────────────
|
||||
|
||||
describe('Direct assigneeUserId', () => {
|
||||
it('authorizes (200) when transition.assigneeUserId matches the current user', async () => {
|
||||
const TRANSITION = {
|
||||
id: 3,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: USER_ID,
|
||||
assigneeConditions: null,
|
||||
conditions: null,
|
||||
};
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([]);
|
||||
mockStorage.getFormFields.mockResolvedValue([]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 3 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 403 when transition.assigneeUserId belongs to someone else', async () => {
|
||||
const TRANSITION = {
|
||||
id: 3,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: 9999,
|
||||
assigneeConditions: null,
|
||||
conditions: null,
|
||||
};
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([]);
|
||||
mockStorage.getFormFields.mockResolvedValue([]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 3 });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tasks/:id/transition — routingRules', () => {
|
||||
const app = buildApp();
|
||||
const token = makeToken(USER_ID, ORG_ID);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStorageDefaults();
|
||||
});
|
||||
|
||||
it('should route to default status when no routing rule matches', async () => {
|
||||
const TRANSITION = {
|
||||
id: 1,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: USER_ID,
|
||||
assigneeConditions: null,
|
||||
conditions: {
|
||||
routingRules: [{ fieldCode: 'field_1', value: 'x', toStatusId: 300 }],
|
||||
defaultToStatusId: 200,
|
||||
},
|
||||
};
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: 1, value: 'y' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: 1, code: 'field_1', type: 'text' },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockStorage.updateTask).toHaveBeenCalledWith(1, ORG_ID, expect.objectContaining({ currentStatusId: 200 }));
|
||||
});
|
||||
|
||||
it('should route to matching status when routingRule uses empty operator', async () => {
|
||||
const TRANSITION = {
|
||||
id: 1,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: USER_ID,
|
||||
assigneeConditions: null,
|
||||
conditions: {
|
||||
routingRules: [{ fieldCode: 'field_1', operator: 'empty', toStatusId: 300 }],
|
||||
defaultToStatusId: 200,
|
||||
},
|
||||
};
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: 1, value: '' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: 1, code: 'field_1', type: 'text' },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockStorage.updateTask).toHaveBeenCalledWith(1, ORG_ID, expect.objectContaining({ currentStatusId: 300 }));
|
||||
});
|
||||
|
||||
it('should route to default status when empty operator does not match (field has value)', async () => {
|
||||
const TRANSITION = {
|
||||
id: 1,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: USER_ID,
|
||||
assigneeConditions: null,
|
||||
conditions: {
|
||||
routingRules: [{ fieldCode: 'field_1', operator: 'empty', toStatusId: 300 }],
|
||||
defaultToStatusId: 200,
|
||||
},
|
||||
};
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: 1, value: 'filled' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: 1, code: 'field_1', type: 'text' },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockStorage.updateTask).toHaveBeenCalledWith(1, ORG_ID, expect.objectContaining({ currentStatusId: 200 }));
|
||||
});
|
||||
|
||||
it('should route to matching status when routingRule uses filled operator', async () => {
|
||||
const TRANSITION = {
|
||||
id: 1,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: USER_ID,
|
||||
assigneeConditions: null,
|
||||
conditions: {
|
||||
routingRules: [{ fieldCode: 'field_1', operator: 'filled', toStatusId: 300 }],
|
||||
defaultToStatusId: 200,
|
||||
},
|
||||
};
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: 1, value: 'has value' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: 1, code: 'field_1', type: 'text' },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockStorage.updateTask).toHaveBeenCalledWith(1, ORG_ID, expect.objectContaining({ currentStatusId: 300 }));
|
||||
});
|
||||
|
||||
it('should route to default status when filled operator does not match (field is empty)', async () => {
|
||||
const TRANSITION = {
|
||||
id: 1,
|
||||
fromStatusId: 100,
|
||||
toStatusId: 200,
|
||||
rejectToStatusId: null,
|
||||
actionType: 'approve',
|
||||
actionName: null,
|
||||
approveButtonText: 'Одобрить',
|
||||
rejectButtonText: null,
|
||||
assigneeUserId: USER_ID,
|
||||
assigneeConditions: null,
|
||||
conditions: {
|
||||
routingRules: [{ fieldCode: 'field_1', operator: 'filled', toStatusId: 300 }],
|
||||
defaultToStatusId: 200,
|
||||
},
|
||||
};
|
||||
mockStorage.getStatusTransitions.mockResolvedValue([TRANSITION]);
|
||||
mockStorage.getTaskFieldValues.mockResolvedValue([
|
||||
{ fieldId: 1, value: '' },
|
||||
]);
|
||||
mockStorage.getFormFields.mockResolvedValue([
|
||||
{ id: 1, code: 'field_1', type: 'text' },
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/1/transition')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ transitionId: 1 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockStorage.updateTask).toHaveBeenCalledWith(1, ORG_ID, expect.objectContaining({ currentStatusId: 200 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user