| import type { InsightsDateRange } from '@n8n/api-types'; |
| import type { LicenseState } from '@n8n/backend-common'; |
| import { mockLogger } from '@n8n/backend-test-utils'; |
| import type { Project } from '@n8n/db'; |
| import type { WorkflowEntity } from '@n8n/db'; |
| import type { IWorkflowDb } from '@n8n/db'; |
| import type { WorkflowExecuteAfterContext } from '@n8n/decorators'; |
| import { Container } from '@n8n/di'; |
| import type { MockProxy } from 'jest-mock-extended'; |
| import { mock } from 'jest-mock-extended'; |
| import { DateTime } from 'luxon'; |
| import type { InstanceSettings } from 'n8n-core'; |
| import type { IRun } from 'n8n-workflow'; |
|
|
| import { createTeamProject } from '@test-integration/db/projects'; |
| import { createWorkflow } from '@test-integration/db/workflows'; |
| import * as testDb from '@test-integration/test-db'; |
| import * as testModules from '@test-integration/test-modules'; |
|
|
| import { |
| createCompactedInsightsEvent, |
| createMetadata, |
| createRawInsightsEvents, |
| } from '../database/entities/__tests__/db-utils'; |
| import type { InsightsRaw } from '../database/entities/insights-raw'; |
| import type { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository'; |
| import { InsightsCollectionService } from '../insights-collection.service'; |
| import { InsightsCompactionService } from '../insights-compaction.service'; |
| import type { InsightsPruningService } from '../insights-pruning.service'; |
| import { InsightsConfig } from '../insights.config'; |
| import { InsightsService } from '../insights.service'; |
|
|
| beforeAll(async () => { |
| await testModules.loadModules(['insights']); |
| await testDb.init(); |
| }); |
|
|
| beforeEach(async () => { |
| await testDb.truncate([ |
| 'InsightsRaw', |
| 'InsightsByPeriod', |
| 'InsightsMetadata', |
| 'WorkflowEntity', |
| 'Project', |
| ]); |
| }); |
|
|
| |
| afterAll(async () => { |
| await testDb.terminate(); |
| }); |
|
|
| describe('startTimers', () => { |
| let insightsService: InsightsService; |
| let compactionService: InsightsCompactionService; |
| let collectionService: InsightsCollectionService; |
| let pruningService: InsightsPruningService; |
| let instanceSettings: MockProxy<InstanceSettings>; |
|
|
| beforeEach(() => { |
| compactionService = mock<InsightsCompactionService>(); |
| collectionService = mock<InsightsCollectionService>(); |
| pruningService = mock<InsightsPruningService>(); |
| instanceSettings = mock<InstanceSettings>({ |
| instanceType: 'main', |
| }); |
| insightsService = new InsightsService( |
| mock<InsightsByPeriodRepository>(), |
| compactionService, |
| collectionService, |
| pruningService, |
| mock<LicenseState>(), |
| instanceSettings, |
| mockLogger(), |
| ); |
|
|
| jest.clearAllMocks(); |
| }); |
|
|
| const setupMocks = ( |
| instanceType: string, |
| isLeader: boolean = false, |
| isPruningEnabled: boolean = false, |
| ) => { |
| (instanceSettings as any).instanceType = instanceType; |
| Object.defineProperty(instanceSettings, 'isLeader', { |
| get: jest.fn(() => isLeader), |
| }); |
| Object.defineProperty(pruningService, 'isPruningEnabled', { |
| get: jest.fn(() => isPruningEnabled), |
| }); |
| }; |
|
|
| test('starts flushing timer for main instance', () => { |
| setupMocks('main', false, false); |
| insightsService.startTimers(); |
|
|
| expect(collectionService.startFlushingTimer).toHaveBeenCalled(); |
| expect(compactionService.startCompactionTimer).not.toHaveBeenCalled(); |
| expect(pruningService.startPruningTimer).not.toHaveBeenCalled(); |
| }); |
|
|
| test('starts compaction and flushing timers for main leader instances', () => { |
| setupMocks('main', true, false); |
| insightsService.startTimers(); |
|
|
| expect(collectionService.startFlushingTimer).toHaveBeenCalled(); |
| expect(compactionService.startCompactionTimer).toHaveBeenCalled(); |
| expect(pruningService.startPruningTimer).not.toHaveBeenCalled(); |
| }); |
|
|
| test('starts compaction, flushing and pruning timers for main leader instance with pruning enabled', () => { |
| setupMocks('main', true, true); |
| insightsService.startTimers(); |
|
|
| expect(collectionService.startFlushingTimer).toHaveBeenCalled(); |
| expect(compactionService.startCompactionTimer).toHaveBeenCalled(); |
| expect(pruningService.startPruningTimer).toHaveBeenCalled(); |
| }); |
|
|
| test('starts only collection flushing timer for webhook instance', () => { |
| setupMocks('webhook', false, false); |
| insightsService.startTimers(); |
|
|
| expect(collectionService.startFlushingTimer).toHaveBeenCalled(); |
| expect(compactionService.startCompactionTimer).not.toHaveBeenCalled(); |
| expect(pruningService.startPruningTimer).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('getInsightsSummary', () => { |
| let insightsService: InsightsService; |
| beforeAll(async () => { |
| insightsService = Container.get(InsightsService); |
| }); |
|
|
| let project: Project; |
| let workflow: IWorkflowDb & WorkflowEntity; |
|
|
| beforeEach(async () => { |
| project = await createTeamProject(); |
| workflow = await createWorkflow({}, project); |
| }); |
|
|
| test('compacted data are summarized correctly', async () => { |
| |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ day: 2 }), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'failure', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 10 }), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'runtime_ms', |
| value: 123, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 10 }), |
| }); |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'runtime_ms', |
| value: 123, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 13 }), |
| }); |
|
|
| |
| const summary = await insightsService.getInsightsSummary({ periodLengthInDays: 6 }); |
|
|
| |
| expect(summary).toEqual({ |
| averageRunTime: { deviation: -123, unit: 'millisecond', value: 0 }, |
| failed: { deviation: 1, unit: 'count', value: 1 }, |
| failureRate: { deviation: 0.333, unit: 'ratio', value: 0.333 }, |
| timeSaved: { deviation: 0, unit: 'minute', value: 0 }, |
| total: { deviation: 2, unit: 'count', value: 3 }, |
| }); |
| }); |
|
|
| test('no data for previous period should return null deviation', async () => { |
| |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
|
|
| |
| const summary = await insightsService.getInsightsSummary({ periodLengthInDays: 7 }); |
|
|
| |
| expect(Object.values(summary).map((v) => v.deviation)).toEqual([null, null, null, null, null]); |
| }); |
|
|
| test('mixed period data are summarized correctly', async () => { |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'hour', |
| periodStart: DateTime.utc(), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ day: 1 }), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'failure', |
| value: 2, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 2, |
| periodUnit: 'hour', |
| periodStart: DateTime.utc().minus({ day: 10 }), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 3, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ day: 11 }), |
| }); |
|
|
| |
| const summary = await insightsService.getInsightsSummary({ periodLengthInDays: 7 }); |
|
|
| |
| expect(summary).toEqual({ |
| averageRunTime: { deviation: 0, unit: 'millisecond', value: 0 }, |
| failed: { deviation: 2, unit: 'count', value: 2 }, |
| failureRate: { deviation: 0.5, unit: 'ratio', value: 0.5 }, |
| timeSaved: { deviation: 0, unit: 'minute', value: 0 }, |
| total: { deviation: -1, unit: 'count', value: 4 }, |
| }); |
| }); |
| }); |
|
|
| describe('getInsightsByWorkflow', () => { |
| let insightsService: InsightsService; |
| beforeAll(async () => { |
| insightsService = Container.get(InsightsService); |
| }); |
|
|
| let project: Project; |
| let workflow1: IWorkflowDb & WorkflowEntity; |
| let workflow2: IWorkflowDb & WorkflowEntity; |
| let workflow3: IWorkflowDb & WorkflowEntity; |
|
|
| beforeEach(async () => { |
| project = await createTeamProject(); |
| workflow1 = await createWorkflow({}, project); |
| workflow2 = await createWorkflow({}, project); |
| workflow3 = await createWorkflow({}, project); |
| }); |
|
|
| test('compacted data are are grouped by workflow correctly', async () => { |
| |
| for (const workflow of [workflow1, workflow2]) { |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: workflow === workflow1 ? 1 : 2, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ day: 2 }), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'failure', |
| value: 2, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 10 }), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'runtime_ms', |
| value: 123, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 10 }), |
| }); |
|
|
| |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'hour', |
| periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), |
| }); |
|
|
| |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 14 }), |
| }); |
| } |
|
|
| |
| const byWorkflow = await insightsService.getInsightsByWorkflow({ |
| maxAgeInDays: 14, |
| }); |
|
|
| |
| expect(byWorkflow.count).toEqual(2); |
| expect(byWorkflow.data).toHaveLength(2); |
|
|
| |
| expect(byWorkflow.data[0]).toMatchObject({ |
| workflowId: workflow2.id, |
| workflowName: workflow2.name, |
| projectId: project.id, |
| projectName: project.name, |
| total: 7, |
| failed: 2, |
| runTime: 123, |
| succeeded: 5, |
| timeSaved: 0, |
| }); |
| expect(byWorkflow.data[0].failureRate).toBeCloseTo(2 / 7); |
| expect(byWorkflow.data[0].averageRunTime).toBeCloseTo(123 / 7); |
|
|
| expect(byWorkflow.data[1]).toMatchObject({ |
| workflowId: workflow1.id, |
| workflowName: workflow1.name, |
| projectId: project.id, |
| projectName: project.name, |
| total: 6, |
| failed: 2, |
| runTime: 123, |
| succeeded: 4, |
| timeSaved: 0, |
| }); |
| expect(byWorkflow.data[1].failureRate).toBeCloseTo(2 / 6); |
| expect(byWorkflow.data[1].averageRunTime).toBeCloseTo(123 / 6); |
| }); |
|
|
| test('compacted data are grouped by workflow correctly with sorting', async () => { |
| |
| for (const workflow of [workflow1, workflow2]) { |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: workflow === workflow1 ? 1 : 2, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'failure', |
| value: 2, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'runtime_ms', |
| value: workflow === workflow1 ? 2 : 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 10 }), |
| }); |
| } |
|
|
| |
| const byWorkflow = await insightsService.getInsightsByWorkflow({ |
| maxAgeInDays: 14, |
| sortBy: 'runTime:desc', |
| }); |
|
|
| |
| expect(byWorkflow.count).toEqual(2); |
| expect(byWorkflow.data).toHaveLength(2); |
| expect(byWorkflow.data[0].workflowId).toEqual(workflow1.id); |
| }); |
|
|
| test('compacted data are grouped by workflow correctly with pagination', async () => { |
| |
| for (const workflow of [workflow1, workflow2, workflow3]) { |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: workflow === workflow1 ? 1 : workflow === workflow2 ? 2 : 3, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| } |
|
|
| |
| const byWorkflow = await insightsService.getInsightsByWorkflow({ |
| maxAgeInDays: 14, |
| sortBy: 'succeeded:desc', |
| skip: 1, |
| take: 1, |
| }); |
|
|
| |
| expect(byWorkflow.count).toEqual(3); |
| expect(byWorkflow.data).toHaveLength(1); |
| expect(byWorkflow.data[0].workflowId).toEqual(workflow2.id); |
| }); |
|
|
| test('compacted data are grouped by workflow correctly even with 0 data (check division by 0)', async () => { |
| |
| const byWorkflow = await insightsService.getInsightsByWorkflow({ |
| maxAgeInDays: 14, |
| }); |
|
|
| |
| expect(byWorkflow.count).toEqual(0); |
| expect(byWorkflow.data).toHaveLength(0); |
| }); |
| }); |
|
|
| describe('getInsightsByTime', () => { |
| let insightsService: InsightsService; |
| beforeAll(async () => { |
| insightsService = Container.get(InsightsService); |
| }); |
|
|
| let project: Project; |
| let workflow1: IWorkflowDb & WorkflowEntity; |
| let workflow2: IWorkflowDb & WorkflowEntity; |
|
|
| beforeEach(async () => { |
| project = await createTeamProject(); |
| workflow1 = await createWorkflow({}, project); |
| workflow2 = await createWorkflow({}, project); |
| }); |
|
|
| test('returns empty array when no insights exist', async () => { |
| const byTime = await insightsService.getInsightsByTime({ maxAgeInDays: 14, periodUnit: 'day' }); |
| expect(byTime).toEqual([]); |
| }); |
|
|
| test('returns empty array when no insights in the time range exists', async () => { |
| await createCompactedInsightsEvent(workflow1, { |
| type: 'success', |
| value: 2, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 30 }), |
| }); |
|
|
| const byTime = await insightsService.getInsightsByTime({ maxAgeInDays: 14, periodUnit: 'day' }); |
| expect(byTime).toEqual([]); |
| }); |
|
|
| test('compacted data are are grouped by time correctly', async () => { |
| |
| for (const workflow of [workflow1, workflow2]) { |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: workflow === workflow1 ? 1 : 2, |
| periodUnit: 'day', |
| periodStart: DateTime.utc(), |
| }); |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'failure', |
| value: 2, |
| periodUnit: 'hour', |
| periodStart: DateTime.utc(), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ day: 2 }), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 10 }), |
| }); |
| await createCompactedInsightsEvent(workflow, { |
| type: 'runtime_ms', |
| value: workflow === workflow1 ? 10 : 20, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 10 }), |
| }); |
|
|
| |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: workflow === workflow1 ? 'success' : 'failure', |
| value: 1, |
| periodUnit: 'hour', |
| periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), |
| }); |
|
|
| |
| |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'day', |
| periodStart: DateTime.utc().minus({ days: 14 }), |
| }); |
| } |
|
|
| |
| const byTime = await insightsService.getInsightsByTime({ maxAgeInDays: 14, periodUnit: 'day' }); |
|
|
| |
| expect(byTime).toHaveLength(4); |
|
|
| |
| expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 14 }).startOf('day').toISO()); |
| expect(byTime[1].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO()); |
| expect(byTime[2].date).toEqual(DateTime.utc().minus({ days: 2 }).startOf('day').toISO()); |
| expect(byTime[3].date).toEqual(DateTime.utc().startOf('day').toISO()); |
|
|
| expect(byTime[0].values).toEqual({ |
| total: 2, |
| succeeded: 1, |
| failed: 1, |
| failureRate: 0.5, |
| averageRunTime: 0, |
| timeSaved: 0, |
| }); |
|
|
| expect(byTime[1].values).toEqual({ |
| total: 2, |
| succeeded: 2, |
| failed: 0, |
| failureRate: 0, |
| averageRunTime: 15, |
| timeSaved: 0, |
| }); |
|
|
| expect(byTime[2].values).toEqual({ |
| total: 2, |
| succeeded: 2, |
| failed: 0, |
| failureRate: 0, |
| averageRunTime: 0, |
| timeSaved: 0, |
| }); |
|
|
| expect(byTime[3].values).toEqual({ |
| total: 7, |
| succeeded: 3, |
| failed: 4, |
| failureRate: 4 / 7, |
| averageRunTime: 0, |
| timeSaved: 0, |
| }); |
| }); |
| }); |
|
|
| describe('getAvailableDateRanges', () => { |
| let licenseMock: jest.Mocked<LicenseState>; |
| let insightsService: InsightsService; |
|
|
| beforeAll(() => { |
| licenseMock = mock<LicenseState>(); |
| insightsService = new InsightsService( |
| mock(), |
| mock(), |
| mock(), |
| mock(), |
| licenseMock, |
| mock(), |
| mockLogger(), |
| ); |
| }); |
|
|
| test('returns correct ranges when hourly data is enabled and max history is unlimited', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(-1); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); |
|
|
| const result = insightsService.getAvailableDateRanges(); |
|
|
| expect(result).toEqual([ |
| { key: 'day', licensed: true, granularity: 'hour' }, |
| { key: 'week', licensed: true, granularity: 'day' }, |
| { key: '2weeks', licensed: true, granularity: 'day' }, |
| { key: 'month', licensed: true, granularity: 'day' }, |
| { key: 'quarter', licensed: true, granularity: 'week' }, |
| { key: '6months', licensed: true, granularity: 'week' }, |
| { key: 'year', licensed: true, granularity: 'week' }, |
| ]); |
| }); |
|
|
| test('returns correct ranges when hourly data is enabled and max history is 365 days', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(365); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); |
|
|
| const result = insightsService.getAvailableDateRanges(); |
|
|
| expect(result).toEqual([ |
| { key: 'day', licensed: true, granularity: 'hour' }, |
| { key: 'week', licensed: true, granularity: 'day' }, |
| { key: '2weeks', licensed: true, granularity: 'day' }, |
| { key: 'month', licensed: true, granularity: 'day' }, |
| { key: 'quarter', licensed: true, granularity: 'week' }, |
| { key: '6months', licensed: true, granularity: 'week' }, |
| { key: 'year', licensed: true, granularity: 'week' }, |
| ]); |
| }); |
|
|
| test('returns correct ranges when hourly data is disabled and max history is 30 days', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(30); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false); |
|
|
| const result = insightsService.getAvailableDateRanges(); |
|
|
| expect(result).toEqual([ |
| { key: 'day', licensed: false, granularity: 'hour' }, |
| { key: 'week', licensed: true, granularity: 'day' }, |
| { key: '2weeks', licensed: true, granularity: 'day' }, |
| { key: 'month', licensed: true, granularity: 'day' }, |
| { key: 'quarter', licensed: false, granularity: 'week' }, |
| { key: '6months', licensed: false, granularity: 'week' }, |
| { key: 'year', licensed: false, granularity: 'week' }, |
| ]); |
| }); |
|
|
| test('returns correct ranges when max history is less than 7 days', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(5); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false); |
|
|
| const result = insightsService.getAvailableDateRanges(); |
|
|
| expect(result).toEqual([ |
| { key: 'day', licensed: false, granularity: 'hour' }, |
| { key: 'week', licensed: false, granularity: 'day' }, |
| { key: '2weeks', licensed: false, granularity: 'day' }, |
| { key: 'month', licensed: false, granularity: 'day' }, |
| { key: 'quarter', licensed: false, granularity: 'week' }, |
| { key: '6months', licensed: false, granularity: 'week' }, |
| { key: 'year', licensed: false, granularity: 'week' }, |
| ]); |
| }); |
|
|
| test('returns correct ranges when max history is 90 days and hourly data is enabled', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(90); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); |
|
|
| const result = insightsService.getAvailableDateRanges(); |
|
|
| expect(result).toEqual([ |
| { key: 'day', licensed: true, granularity: 'hour' }, |
| { key: 'week', licensed: true, granularity: 'day' }, |
| { key: '2weeks', licensed: true, granularity: 'day' }, |
| { key: 'month', licensed: true, granularity: 'day' }, |
| { key: 'quarter', licensed: true, granularity: 'week' }, |
| { key: '6months', licensed: false, granularity: 'week' }, |
| { key: 'year', licensed: false, granularity: 'week' }, |
| ]); |
| }); |
| }); |
|
|
| describe('getMaxAgeInDaysAndGranularity', () => { |
| let insightsService: InsightsService; |
| let licenseMock: jest.Mocked<LicenseState>; |
|
|
| beforeAll(() => { |
| licenseMock = mock<LicenseState>(); |
| insightsService = new InsightsService( |
| mock<InsightsByPeriodRepository>(), |
| mock<InsightsCompactionService>(), |
| mock<InsightsCollectionService>(), |
| mock<InsightsPruningService>(), |
| licenseMock, |
| mock<InstanceSettings>(), |
| mockLogger(), |
| ); |
| }); |
|
|
| test('returns correct maxAgeInDays and granularity for a valid licensed date range', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(365); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); |
|
|
| const result = insightsService.getMaxAgeInDaysAndGranularity('month'); |
|
|
| expect(result).toEqual({ |
| key: 'month', |
| licensed: true, |
| granularity: 'day', |
| maxAgeInDays: 30, |
| }); |
| }); |
|
|
| test('throws an error if the date range is not available', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(365); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); |
|
|
| expect(() => { |
| insightsService.getMaxAgeInDaysAndGranularity('invalidKey' as InsightsDateRange['key']); |
| }).toThrowError('The selected date range is not available'); |
| }); |
|
|
| test('throws an error if the date range is not licensed', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(30); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false); |
|
|
| expect(() => { |
| insightsService.getMaxAgeInDaysAndGranularity('year'); |
| }).toThrowError('The selected date range exceeds the maximum history allowed by your license.'); |
| }); |
|
|
| test('returns correct maxAgeInDays and granularity for a valid date range with hourly data disabled', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(90); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false); |
|
|
| const result = insightsService.getMaxAgeInDaysAndGranularity('quarter'); |
|
|
| expect(result).toEqual({ |
| key: 'quarter', |
| licensed: true, |
| granularity: 'week', |
| maxAgeInDays: 90, |
| }); |
| }); |
|
|
| test('returns correct maxAgeInDays and granularity for a valid date range with unlimited history', () => { |
| licenseMock.getInsightsMaxHistory.mockReturnValue(-1); |
| licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); |
|
|
| const result = insightsService.getMaxAgeInDaysAndGranularity('day'); |
|
|
| expect(result).toEqual({ |
| key: 'day', |
| licensed: true, |
| granularity: 'hour', |
| maxAgeInDays: 1, |
| }); |
| }); |
| }); |
|
|
| describe('shutdown', () => { |
| let insightsService: InsightsService; |
|
|
| const mockCollectionService = mock<InsightsCollectionService>({ |
| shutdown: jest.fn().mockResolvedValue(undefined), |
| stopFlushingTimer: jest.fn(), |
| }); |
|
|
| const mockCompactionService = mock<InsightsCompactionService>({ |
| stopCompactionTimer: jest.fn(), |
| }); |
|
|
| const mockPruningService = mock<InsightsPruningService>({ |
| stopPruningTimer: jest.fn(), |
| }); |
|
|
| beforeAll(() => { |
| insightsService = new InsightsService( |
| mock<InsightsByPeriodRepository>(), |
| mockCompactionService, |
| mockCollectionService, |
| mockPruningService, |
| mock<LicenseState>(), |
| mock<InstanceSettings>(), |
| mockLogger(), |
| ); |
| }); |
|
|
| test('shutdown stops timers and shuts down services', async () => { |
| |
| await insightsService.shutdown(); |
|
|
| |
| expect(mockCollectionService.shutdown).toHaveBeenCalled(); |
| expect(mockCompactionService.stopCompactionTimer).toHaveBeenCalled(); |
| expect(mockPruningService.stopPruningTimer).toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('legacy sqlite (without pooling) handles concurrent insights db process without throwing', () => { |
| let initialFlushBatchSize: number; |
| let insightsConfig: InsightsConfig; |
| beforeAll(() => { |
| insightsConfig = Container.get(InsightsConfig); |
| initialFlushBatchSize = insightsConfig.flushBatchSize; |
|
|
| insightsConfig.flushBatchSize = 50; |
| }); |
|
|
| afterAll(() => { |
| insightsConfig.flushBatchSize = initialFlushBatchSize; |
| }); |
|
|
| test('should handle concurrent flush and compaction without error', async () => { |
| const insightsCollectionService = Container.get(InsightsCollectionService); |
| const insightsCompactionService = Container.get(InsightsCompactionService); |
|
|
| const project = await createTeamProject(); |
| const workflow = await createWorkflow({}, project); |
| await createMetadata(workflow); |
|
|
| const ctx = mock<WorkflowExecuteAfterContext>({ workflow }); |
| const startedAt = DateTime.utc(); |
| const stoppedAt = startedAt.plus({ seconds: 5 }); |
| ctx.runData = mock<IRun>({ |
| mode: 'webhook', |
| status: 'success', |
| startedAt: startedAt.toJSDate(), |
| stoppedAt: stoppedAt.toJSDate(), |
| }); |
|
|
| |
| const rawInsights = []; |
| for (let i = 0; i < 100; i++) { |
| rawInsights.push({ |
| type: 'success' as InsightsRaw['type'], |
| value: 1, |
| periodUnit: 'hour', |
| periodStart: DateTime.now().minus({ day: 91, hour: i + 1 }), |
| }); |
| } |
| |
| await createRawInsightsEvents(workflow, rawInsights); |
|
|
| |
| for (let i = 0; i < 100; i++) { |
| await createCompactedInsightsEvent(workflow, { |
| type: 'success', |
| value: 1, |
| periodUnit: 'hour', |
| periodStart: DateTime.now().minus({ day: 91, hour: i + 1 }), |
| }); |
| } |
|
|
| for (let i = 0; i < 100; i++) { |
| await insightsCollectionService.handleWorkflowExecuteAfter(ctx); |
| } |
|
|
| |
| const promises = [ |
| insightsCollectionService.flushEvents(), |
| insightsCollectionService.flushEvents(), |
| insightsCompactionService.compactRawToHour(), |
| insightsCompactionService.compactHourToDay(), |
| ]; |
| await expect(Promise.all(promises)).resolves.toBeDefined(); |
| }); |
| }); |
|
|