Files
iistwin/tests/task-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

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