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