- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
661 lines
22 KiB
TypeScript
661 lines
22 KiB
TypeScript
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 }));
|
|
});
|
|
});
|
|
});
|