diff --git a/bun.lockb b/bun.lockb index 94886d7ec58aab76974728e32acbce8e5b53710b..60c32177a430fc1b9b24ea51dc61fd09c778caf1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 548cf5cda331f384e8c4f132516a91c44403dfac..618e5a6384f3c1e38bf1f17daf499bb0011f0e63 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "1.0.0", "scripts": { "build": "tsc", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest", "dev": "bun run --watch --inspect=6498 src/index.ts", "start": "bun run --production src/index.ts", "lint": "eslint src/**/* --no-error-on-unmatched-pattern" @@ -20,7 +20,8 @@ "@typescript-eslint/eslint-plugin": "^8.19.1", "@typescript-eslint/parser": "^8.19.1", "eslint": "^9.17.0", - "typescript-eslint": "^8.19.1" + "typescript-eslint": "^8.19.1", + "vitest": "^3.0.8" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/src/index.ts b/src/index.ts index 15b50d023b91c9ffbd1f266899e342fea313cdd1..34c70e3536b9dcecda1a5f95b0fd063ae9b55338 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ async function main() { log.info('Connected to Redis!'); await queryServiceReader(frontendPublisher, mlPublisher, 'neo4j'); - await insightProcessor(); + await insightProcessor(frontendPublisher); } await main(); diff --git a/src/readers/diffCheck.ts b/src/readers/diffCheck.ts index ba3dd09995ef4a2070b372f7e2363a18ca85f957..eba2c6cc1a131b5a8434019cfe8affebbd47792b 100644 --- a/src/readers/diffCheck.ts +++ b/src/readers/diffCheck.ts @@ -5,6 +5,10 @@ import type { GraphQueryResultMetaFromBackend } from 'ts-common/src/model/webSoc import { ums } from '../variables'; import type { InsightModel } from 'ts-common'; +export const compareHashedQueryResults = (previousHash: string | null, currentHash: string): boolean => { + return !previousHash || !hashIsEqual(currentHash, previousHash); +}; + export const diffCheck = async ( insight: InsightModel, ss: SaveState, @@ -20,7 +24,8 @@ export const diffCheck = async ( }); log.debug('Comparing hash values from current and previous query'); - const changed = !previousQueryResult || !hashIsEqual(queryResultHash, previousQueryResult); + const changed = compareHashedQueryResults(previousQueryResult, queryResultHash); + insight.status ||= changed; log.debug('Updated node and edge ids in SaveState'); diff --git a/src/readers/insightProcessor.ts b/src/readers/insightProcessor.ts index 71014ea1bd75cbed02dab9f764bf143caac55d3a..1fb06370d65960efea417fc87629bc9b2158a058 100644 --- a/src/readers/insightProcessor.ts +++ b/src/readers/insightProcessor.ts @@ -1,4 +1,4 @@ -import { rabbitMq, ums, mail, SMTP_USER, DEBUG_EMAIL } from '../variables'; +import { rabbitMq, ums, mail, SMTP_USER, DEBUG_EMAIL, ENV } from '../variables'; import { log } from '../logger'; import { type InsightModel } from 'ts-common'; import { createHeadlessEditor } from '@lexical/headless'; @@ -12,6 +12,9 @@ import { diffCheck } from './diffCheck'; import { VariableNode } from '../utils/lexical'; import { populateTemplate } from '../utils/insights'; import { RabbitMqBroker } from 'ts-common/rabbitMq'; +import { validateInsight } from '../utils/insights/validateInsight'; +import { validateTypeInsight } from '../utils/insights/validateTypeInsight'; +import { wsReturnKey, type WsMessageBackend2Frontend } from 'ts-common'; const dom = new JSDOM(); function setUpDom() { @@ -34,10 +37,10 @@ function setUpDom() { }; } -export const insightProcessor = async () => { +export const insightProcessor = async (frontendPublisher: RabbitMqBroker) => { if (mail == null) { log.warn('Mail is not configured. Insight processor will be disabled'); - return; + //return; } log.info('Starting insight processor'); @@ -52,18 +55,33 @@ export const insightProcessor = async () => { await insightProcessorConsumer.startConsuming<{ insight: InsightModel; force: boolean }>('query-service', async (message, headers) => { let insight = message.insight; - if (insight == null || insight.template == null || insight.userId == null || insight.saveStateId == null) { - log.error('Invalid Insight received in insightProcessorConsumer:', insight); - return; - } - if (insight.alarmMode === 'disabled' && !message.force) { - log.debug('Alarm mode is disabled', insight.id); + if (!validateInsight(insight, message.force)) { + if (ENV == 'develop') { + const message: WsMessageBackend2Frontend = { + type: wsReturnKey.error, + callID: headers.callID, + status: 'success', + value: 'Insight no valid', + }; + + frontendPublisher.publishMessageToFrontend(message, headers.routingKey, headers); + } return; } - if (insight.recipients == null || insight.recipients.length === 0) { - log.debug('No recipients found in the insight, skipping'); + if (!validateTypeInsight(insight, message.force)) { + if (ENV == 'develop') { + const message: WsMessageBackend2Frontend = { + type: wsReturnKey.error, + callID: headers.callID, + status: 'success', + value: 'Insight Type no valid', + }; + + frontendPublisher.publishMessageToFrontend(message, headers.routingKey, headers); + } + return; } @@ -81,8 +99,7 @@ export const insightProcessor = async () => { editor.setEditorState(state); }); - if (insight.userId == null) return; - const ss = await ums.getUserSaveState(insight.userId, insight.saveStateId); + const ss = await ums.getUserSaveState(insight.userId as number, insight.saveStateId); const queries = ss.queryStates.openQueryArray; const visualizations = ss.visualizations.openVisualizationArray; @@ -106,8 +123,18 @@ export const insightProcessor = async () => { insight = statCheck(insight, result); } - if (insight.userId == null) return; // fixes ts but never is the case - await ums.updateInsight(insight.userId, insight.id, insight); + await ums.updateInsight(insight.userId as number, insight.id, insight); + + if (ENV == 'develop') { + const message: WsMessageBackend2Frontend = { + type: wsReturnKey.insightResult, + callID: headers.callID, + status: 'success', + value: insight, + }; + + frontendPublisher.publishMessageToFrontend(message, headers.routingKey, headers); + } if (insight.status || message.force) { if (insight.status) log.debug('Insight passed the check'); diff --git a/src/readers/queryService.ts b/src/readers/queryService.ts index 4609e9363341d77ad898702ae7ee2ab4cb955347..765a995e01b8c4b37126cd917a62ad5f4de574ba 100644 --- a/src/readers/queryService.ts +++ b/src/readers/queryService.ts @@ -87,6 +87,8 @@ export const queryServiceReader = async (frontendPublisher: RabbitMqBroker, mlPu } log.info('Starting query reader for', type); + const publisher = new QueryPublisher(frontendPublisher, mlPublisher); + const queryServiceConsumer = await new RabbitMqBroker( rabbitMq, 'requests-exchange', diff --git a/src/readers/statCheck.ts b/src/readers/statCheck.ts index 96ea6556b1db0472ddba29e5fa8def1036598bf6..578f9cb365ae8bc894aba24c471773aedf630bff 100644 --- a/src/readers/statCheck.ts +++ b/src/readers/statCheck.ts @@ -1,7 +1,7 @@ import type { GraphQueryResultMetaFromBackend, InsightModel } from 'ts-common'; import { log } from '../logger'; -function processAlarmStats(alarmStat: InsightModel, resultQuery: GraphQueryResultMetaFromBackend): boolean { +export function processAlarmStats(alarmStat: InsightModel, resultQuery: GraphQueryResultMetaFromBackend): boolean { for (const condition of alarmStat.conditionsCheck) { const ssInsightNode = condition.nodeLabel; const ssInsightStatistic = condition.statistic; diff --git a/src/tests/insights/diffCheck.test.ts b/src/tests/insights/diffCheck.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0d0c6490d7c5cd1809c877b213ed04f6b8ecb13 --- /dev/null +++ b/src/tests/insights/diffCheck.test.ts @@ -0,0 +1,68 @@ +import { expect, test, describe, it } from 'vitest'; +import type { GraphQueryResultMetaFromBackend } from 'ts-common'; +import { hashDictionary, hashIsEqual } from '../../utils/hashing'; +import { compareHashedQueryResults } from './../../readers/diffCheck'; +import { Logger } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); + +describe('Hash Comparison Tests', () => { + it('should detect different hashes for different graph structures', () => { + // First query result + const query1: GraphQueryResultMetaFromBackend = { + nodes: [{ _id: 'node1' }, { _id: 'node2' }], + edges: [{ _id: 'edge1' }], + } as GraphQueryResultMetaFromBackend; + + // Second query result with different structure + const query2: GraphQueryResultMetaFromBackend = { + nodes: [{ _id: 'node1' }, { _id: 'node2' }, { _id: 'node3' }], + edges: [{ _id: 'edge1' }, { _id: 'edge2' }], + } as GraphQueryResultMetaFromBackend; + + const hash1 = hashDictionary({ + nodes: query1.nodes.map(node => node._id), + edges: query1.edges.map(edge => edge._id), + }); + + const hash2 = hashDictionary({ + nodes: query2.nodes.map(node => node._id), + edges: query2.edges.map(edge => edge._id), + }); + + // Test direct hash comparison + expect(hashIsEqual(hash1, hash2)).toBe(false); + + // Test using compareHashedQueryResults + expect(compareHashedQueryResults(hash1, hash2)).toBe(true); + }); + + it('should detect identical hashes for same graph structures', () => { + const query1 = { + nodes: [{ _id: 'node1' }, { _id: 'node2' }], + edges: [{ _id: 'edge1' }], + } as GraphQueryResultMetaFromBackend; + + const query2 = { + nodes: [{ _id: 'node1' }, { _id: 'node2' }], + edges: [{ _id: 'edge1' }], + } as GraphQueryResultMetaFromBackend; + + const hash1 = hashDictionary({ + nodes: query1.nodes.map(node => node._id), + edges: query1.edges.map(edge => edge._id), + }); + + const hash2 = hashDictionary({ + nodes: query2.nodes.map(node => node._id), + edges: query2.edges.map(edge => edge._id), + }); + + // Test direct hash comparison + expect(hashIsEqual(hash1, hash2)).toBe(true); + + // Test using compareHashedQueryResults + expect(compareHashedQueryResults(hash1, hash2)).toBe(false); + }); +}); diff --git a/src/tests/insights/populateTemplate.test.ts b/src/tests/insights/populateTemplate.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce8e70b01e58981bfc110d9f46eb6872d1f37ead --- /dev/null +++ b/src/tests/insights/populateTemplate.test.ts @@ -0,0 +1,54 @@ +import { expect, test, describe, it } from 'vitest'; +import { populateTemplate } from './../../utils/insights'; +import { type GraphQueryResultMetaFromBackend } from 'ts-common'; +import { Logger } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); + +const mockResult: GraphQueryResultMetaFromBackend = { + metaData: { + topological: { + density: 0, + self_loops: 0, + }, + nodes: { + count: 1, + labels: ['NodeTypeA'], + types: { + NodeTypeA: { + count: 1, + attributes: { + age: { + attributeType: 'number', + statistics: { + min: 10, + max: 50, + average: 42, + count: 100, + }, + }, + }, + }, + }, + }, + edges: { + count: 0, + labels: [], + types: {}, + }, + }, + nodeCounts: { updatedAt: 2313 }, + nodes: [], + edges: [], +}; + +describe('populateTemplate', () => { + it('should replace statistic variables correctly', async () => { + const template = 'The mean value is {{ statistic:NodeTypeA • age • average }}.'; + const expectedOutput = 'The mean value is 42 .'; + const result = await populateTemplate(template, mockResult, []); + + expect(result).toBe(expectedOutput); + }); +}); diff --git a/src/tests/insights/statCheck.test.ts b/src/tests/insights/statCheck.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6b1fd30cd5b37acf8b651f31faf86c489de8155 --- /dev/null +++ b/src/tests/insights/statCheck.test.ts @@ -0,0 +1,380 @@ +import { expect, test, describe, it } from 'vitest'; +import type { GraphQueryResultMetaFromBackend, InsightModel } from 'ts-common'; +import { processAlarmStats } from './../../readers/statCheck'; +import { Logger } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); + +const baseInsight: Omit<InsightModel, 'conditionsCheck'> = { + id: 1, + name: 'Test Insight', + description: 'Base insight for testing', + recipients: ['test@example.com'], + frequency: 'daily', + template: 'default', + saveStateId: 'save-state-1', + type: 'alert', + alarmMode: 'conditional', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + previousResultHash: 'abc123', + lastProcessedAt: new Date().toISOString(), + status: true, +}; + +describe('QueryprocessAlarmStats', () => { + it('should return true when condition is met', () => { + const alarmStat: InsightModel = { + ...baseInsight, + conditionsCheck: [ + { + nodeLabel: 'TestNode', + statistic: 'count', + operator: '>', + value: 1, + }, + ], + }; + + const resultQuery: GraphQueryResultMetaFromBackend = { + nodes: [ + { + _id: 'node1', + label: 'TestNode', + attributes: { + name: 'Test Node 1', + value: 123, + }, + }, + { + _id: 'node2', + label: 'TestNode', + attributes: { + name: 'Test Node 2', + value: 456, + }, + }, + ], + edges: [ + { + _id: 'edge1', + label: 'CONNECTS', + from: 'node1', + to: 'node2', + attributes: { + weight: 1, + timestamp: '2025-03-06', + }, + }, + ], + metaData: { + topological: { + density: 0.5, + self_loops: 0, + }, + nodes: { + count: 2, + labels: ['TestNode'], + types: { + TestNode: { + count: 2, + avgDegreeIn: 0.5, + avgDegreeOut: 0.5, + attributes: { + name: { + attributeType: 'string', + statistics: { + uniqueItems: 2, + values: ['Test Node 1', 'Test Node 2'], + mode: 'Test Node 1', + count: 2, + }, + }, + value: { + attributeType: 'number', + statistics: { + min: 123, + max: 456, + average: 289.5, + count: 2, + }, + }, + }, + }, + }, + }, + edges: { + count: 1, + labels: ['CONNECTS'], + types: { + CONNECTS: { + count: 1, + attributes: { + weight: { + attributeType: 'number', + statistics: { + min: 1, + max: 1, + average: 1, + count: 1, + }, + }, + timestamp: { + attributeType: 'datetime', + statistics: { + min: 1743782400, + max: 1743782400, + range: 0, + }, + }, + }, + }, + }, + }, + }, + nodeCounts: { + TestNode: 2, + updatedAt: 122331, + }, + }; + + expect(processAlarmStats(alarmStat, resultQuery)).toBe(true); + }); + + it('should return false when condition is not met', () => { + const alarmStat: InsightModel = { + ...baseInsight, + conditionsCheck: [ + { + nodeLabel: 'TestNode', + statistic: 'count', + operator: '<', + value: 1, + }, + ], + }; + + const resultQuery: GraphQueryResultMetaFromBackend = { + nodes: [ + { + _id: 'node1', + label: 'TestNode', + attributes: { + name: 'Test Node 1', + value: 123, + }, + }, + { + _id: 'node2', + label: 'TestNode', + attributes: { + name: 'Test Node 2', + value: 456, + }, + }, + ], + edges: [ + { + _id: 'edge1', + label: 'CONNECTS', + from: 'node1', + to: 'node2', + attributes: { + weight: 1, + timestamp: '2025-03-06', + }, + }, + ], + metaData: { + topological: { + density: 0.5, + self_loops: 0, + }, + nodes: { + count: 2, + labels: ['TestNode'], + types: { + TestNode: { + count: 2, + avgDegreeIn: 0.5, + avgDegreeOut: 0.5, + attributes: { + name: { + attributeType: 'string', + statistics: { + uniqueItems: 2, + values: ['Test Node 1', 'Test Node 2'], + mode: 'Test Node 1', + count: 2, + }, + }, + value: { + attributeType: 'number', + statistics: { + min: 123, + max: 456, + average: 289.5, + count: 2, + }, + }, + }, + }, + }, + }, + edges: { + count: 1, + labels: ['CONNECTS'], + types: { + CONNECTS: { + count: 1, + attributes: { + weight: { + attributeType: 'number', + statistics: { + min: 1, + max: 1, + average: 1, + count: 1, + }, + }, + timestamp: { + attributeType: 'datetime', + statistics: { + min: 1743782400, + max: 1743782400, + range: 0, + }, + }, + }, + }, + }, + }, + }, + nodeCounts: { + TestNode: 2, + updatedAt: 122331, + }, + }; + + expect(processAlarmStats(alarmStat, resultQuery)).toBe(false); + }); + + it('should return false when nodeLabel is not found', () => { + const alarmStat: InsightModel = { + ...baseInsight, + conditionsCheck: [ + { + nodeLabel: 'NonExistentNode', + statistic: 'count', + operator: '>', + value: 5, + }, + ], + }; + + const resultQuery: GraphQueryResultMetaFromBackend = { + nodes: [ + { + _id: 'node1', + label: 'TestNode', + attributes: { + name: 'Test Node 1', + value: 123, + }, + }, + { + _id: 'node2', + label: 'TestNode', + attributes: { + name: 'Test Node 2', + value: 456, + }, + }, + ], + edges: [ + { + _id: 'edge1', + label: 'CONNECTS', + from: 'node1', + to: 'node2', + attributes: { + weight: 1, + timestamp: '2025-03-06', + }, + }, + ], + metaData: { + topological: { + density: 0.5, + self_loops: 0, + }, + nodes: { + count: 2, + labels: ['TestNode'], + types: { + TestNode: { + count: 2, + avgDegreeIn: 0.5, + avgDegreeOut: 0.5, + attributes: { + name: { + attributeType: 'string', + statistics: { + uniqueItems: 2, + values: ['Test Node 1', 'Test Node 2'], + mode: 'Test Node 1', + count: 2, + }, + }, + value: { + attributeType: 'number', + statistics: { + min: 123, + max: 456, + average: 289.5, + count: 2, + }, + }, + }, + }, + }, + }, + edges: { + count: 1, + labels: ['CONNECTS'], + types: { + CONNECTS: { + count: 1, + attributes: { + weight: { + attributeType: 'number', + statistics: { + min: 1, + max: 1, + average: 1, + count: 1, + }, + }, + timestamp: { + attributeType: 'datetime', + statistics: { + min: 1743782400, + max: 1743782400, + range: 0, + }, + }, + }, + }, + }, + }, + }, + nodeCounts: { + TestNode: 2, + updatedAt: 122331, + }, + }; + + expect(processAlarmStats(alarmStat, resultQuery)).toBe(false); + }); +}); diff --git a/src/tests/insights/validateInsight.test.ts b/src/tests/insights/validateInsight.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..15a06c8d05305c89e1f247fc6160816d053a25bc --- /dev/null +++ b/src/tests/insights/validateInsight.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest'; +import { validateInsight } from '../../utils/insights/validateInsight'; +import type { InsightModel, InsightRequest } from 'ts-common'; +import { log } from '../../logger'; +import { Logger } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); + +describe('validateInsight', () => { + const baseInsight: InsightModel = { + id: 1, + name: 'Test Insight', + description: 'A test insight', + recipients: ['user@example.com'], + frequency: 'daily', + saveStateId: '1c9351d5-0dc5-4c08-979f-829f9f83bb29', + userId: 42, + status: true, + type: 'report', + template: 'this is an email', + alarmMode: 'conditional', + createdAt: '2023-12-12', + updatedAt: '2023-12-12', + conditionsCheck: [{ nodeLabel: 'Movie', statistic: 'Count', operator: '>', value: 50 }], + }; + + it('should return true for a valid insight', () => { + expect(validateInsight(baseInsight, true)).toBe(true); + }); + + it('should return false if insight is null', () => { + expect(validateInsight(null as unknown as InsightModel, true)).toBe(false); + }); + + it('should return false if required fields are missing', () => { + const invalidInsight = { ...baseInsight, template: null }; + expect(validateInsight(invalidInsight as unknown as InsightModel, true)).toBe(false); + }); + + it('should return false if alarm mode is disabled and force is false', () => { + const disabledInsight: InsightModel = { + ...baseInsight, + alarmMode: 'disabled', + }; + expect(validateInsight(disabledInsight, false)).toBe(false); + }); + + it('should return true if alarm mode is disabled but force is true', () => { + const disabledInsight: InsightModel = { ...baseInsight, alarmMode: 'disabled' }; + expect(validateInsight(disabledInsight, true)).toBe(true); + }); + + it('should return false if no recipients are provided', () => { + const noRecipientsInsight = { ...baseInsight, recipients: [] }; + expect(validateInsight(noRecipientsInsight, true)).toBe(false); + }); + + it('should return false if userId is null', () => { + const noUserInsight: InsightModel = { ...baseInsight, userId: null as unknown as number }; + expect(validateInsight(noUserInsight, true)).toBe(false); + }); +}); diff --git a/src/tests/insights/validateTypeInsight.test.ts b/src/tests/insights/validateTypeInsight.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cf8df18fa1beace1616798792d2431a49404349 --- /dev/null +++ b/src/tests/insights/validateTypeInsight.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { validateTypeInsight } from '../../utils/insights/validateTypeInsight'; +import { Logger } from 'ts-common'; +import type { InsightModel, AlarmModes, conditionalOperators } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); + +describe('validateTypeInsight', () => { + const baseInsight: InsightModel = { + id: 1, + name: 'Test Insight', + description: 'A test insight for validation', + recipients: [], + template: 'default', + conditionsCheck: [], + frequency: 'daily', + previousResultHash: 'hash', + createdAt: '', + updatedAt: '', + saveStateId: '', + type: 'report', + alarmMode: 'conditional', + }; + + it('should return false when alarmMode is "disabled" and force is false', () => { + const insight = { ...baseInsight, alarmMode: 'disabled' as AlarmModes }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(false); + }); + + it('should return true when alarmMode is "disabled" and force is true', () => { + const insight = { ...baseInsight, alarmMode: 'disabled' as AlarmModes }; + const result = validateTypeInsight(insight, true); + expect(result).toBe(true); + }); + + it('should return false when conditionsCheck is empty in "conditional" alarmMode', () => { + const insight = { ...baseInsight, alarmMode: 'conditional' as AlarmModes, conditionsCheck: [] }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(false); + }); + + it('should return false when a condition is invalid in "conditional" alarmMode', () => { + const insight = { + ...baseInsight, + alarmMode: 'conditional' as AlarmModes, + conditionsCheck: [{ nodeLabel: '', statistic: 'average', operator: '>' as conditionalOperators, value: 10 }], + }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(false); + }); + + it('should return true when all conditions are valid in "conditional" alarmMode', () => { + const insight = { + ...baseInsight, + alarmMode: 'conditional' as AlarmModes, + conditionsCheck: [{ nodeLabel: 'label', statistic: 'average', operator: '>' as conditionalOperators, value: 10 }], + }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(true); + }); + + it('should return false when frequency or previousResultHash is missing in "diff" alarmMode', () => { + const insight = { ...baseInsight, alarmMode: 'diff' as AlarmModes, frequency: '', previousResultHash: '' }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(false); + }); + + it('should return true when frequency and previousResultHash are provided in "diff" alarmMode', () => { + const insight = { ...baseInsight, alarmMode: 'diff' as AlarmModes, frequency: 'daily', previousResultHash: 'hash' }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(true); + }); + + it('should return false when frequency is missing in "always" alarmMode', () => { + const insight = { ...baseInsight, alarmMode: 'always' as AlarmModes, frequency: '' }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(false); + }); + + it('should return true when frequency is provided in "always" alarmMode', () => { + const insight = { ...baseInsight, alarmMode: 'always' as AlarmModes, frequency: 'daily' }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(true); + }); + + it('should return true for unknown alarmMode', () => { + const insight = { ...baseInsight, alarmMode: 'unknown' as any }; + const result = validateTypeInsight(insight, false); + expect(result).toBe(true); + }); +}); diff --git a/src/utils/cypher/converter/queryConverter.test.ts b/src/tests/query/queryConverter.test.ts similarity index 98% rename from src/utils/cypher/converter/queryConverter.test.ts rename to src/tests/query/queryConverter.test.ts index 5227c6cd6ed1d867142f8bacd4718795b3702f23..f5e819d779bee110c7945c121fbc0e559481ee06 100644 --- a/src/utils/cypher/converter/queryConverter.test.ts +++ b/src/tests/query/queryConverter.test.ts @@ -1,6 +1,10 @@ -import { query2Cypher } from './queryConverter'; +import { query2Cypher } from '../../utils/cypher/converter/queryConverter'; import { StringFilterTypes, type BackendQueryFormat } from 'ts-common'; -import { expect, test, describe, it } from 'bun:test'; +import { expect, describe, it } from 'vitest'; +import { Logger } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); function fixCypherSpaces(cypher?: string | null): string { if (!cypher) { diff --git a/src/tests/query/queryTranslator/queryTranslator.test.ts b/src/tests/query/queryTranslator/queryTranslator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..85e7d47fbb1e5edf21f3856333ced1dba5aa9505 --- /dev/null +++ b/src/tests/query/queryTranslator/queryTranslator.test.ts @@ -0,0 +1,96 @@ +import { expect, describe, it } from 'vitest'; +import type { QueryMultiGraph } from 'ts-common/src/model/graphology'; +import { MLTypesEnum } from 'ts-common/src/model/query/machineLearningModel'; +import { type QueryBuilderSettings } from 'ts-common/src/model/query/queryBuilderModel'; +import { Query2BackendQuery } from './../../../utils/reactflow/query2backend'; +import type { MachineLearning } from 'ts-common/src/model/query/queryRequestModel'; +import { type BackendQueryFormat } from 'ts-common'; +import { createQueryMultiGraphFromData, settingsBase, ss_id } from './testData'; +import { visualQuery_1 } from './testData'; +import { expectedResult_1 } from './testData'; +import { Logger } from 'ts-common'; + +Logger.excludedOwners.push('ts-common'); +Logger.excludedOwners.push('query-service'); + +describe('query2backend', () => { + it('should return correctly a node - 0', () => { + const nodesData = [ + { + id: 'Movie', + schemaKey: 'Movie', + type: 'entity', + width: 100, + height: 100, + x: 50, + y: 50, + name: 'Movie', + attributes: [ + { name: '(# Connection)', type: 'float' }, + { name: 'tagline', type: 'string' }, + { name: 'votes', type: 'int' }, + { name: 'title', type: 'string' }, + { name: 'released', type: 'int' }, + ], + }, + ]; + + const visualQuery: QueryMultiGraph = createQueryMultiGraphFromData(nodesData, []); + + const ml: MachineLearning[] = [ + { type: MLTypesEnum.linkPrediction, parameters: [], id: 1 }, + { type: MLTypesEnum.centrality, parameters: [], id: 2 }, + { type: MLTypesEnum.communityDetection, parameters: [], id: 3 }, + { type: MLTypesEnum.shortestPath, parameters: [], id: 4 }, + ]; + + const result = Query2BackendQuery(ss_id, visualQuery, settingsBase, ml); + const expectedResult: BackendQueryFormat = { + saveStateID: 'test', + query: [ + { + id: 'path_0', + node: { + label: 'Movie', + id: 'Movie', + relation: undefined, + }, + }, + ], + machineLearning: [ + { type: MLTypesEnum.linkPrediction, parameters: [], id: 1 }, + { type: MLTypesEnum.centrality, parameters: [], id: 2 }, + { type: MLTypesEnum.communityDetection, parameters: [], id: 3 }, + { type: MLTypesEnum.shortestPath, parameters: [], id: 4 }, + ], + limit: 500, + return: ['*'], + cached: false, + logic: undefined, + }; + expect(result).toEqual(expectedResult); + }); + it('should return correctly on a simple query with multiple paths - 1', () => { + const ml: MachineLearning[] = [ + { type: MLTypesEnum.linkPrediction, parameters: [], id: 1 }, + { type: MLTypesEnum.centrality, parameters: [], id: 2 }, + { type: MLTypesEnum.communityDetection, parameters: [], id: 3 }, + { type: MLTypesEnum.shortestPath, parameters: [], id: 4 }, + ]; + + const result = Query2BackendQuery(ss_id, visualQuery_1, settingsBase, ml); + + expect(result).toEqual(expectedResult_1); + }); + /* + it('should return correctly on a complex query with logic', () => {}); + it('should return correctly on a query with group by logic', () => {}); + it('should return correctly on a query with no label', () => {}); + it('should return correctly on a query with no depth', () => {}); + it('should return correctly on a query with average calculation', () => {}); + it('should return correctly on a query with average calculation and multiple paths', () => {}); + it('should return correctly on a single entity query with lower like logic', () => {}); + it('should return correctly on a query with like logic', () => {}); + it('should return correctly on a query with both direction relation', () => {}); +*/ +}); diff --git a/src/tests/query/queryTranslator/testData.ts b/src/tests/query/queryTranslator/testData.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a3154ec2179a1f5a4c584ea54b359355228caf9 --- /dev/null +++ b/src/tests/query/queryTranslator/testData.ts @@ -0,0 +1,541 @@ +import type { QueryMultiGraph, QueryGraphNodes, QueryGraphEdges } from 'ts-common/src/model/graphology'; +import type { SerializedNode, SerializedEdge } from 'graphology-types'; +import { Handles, QueryElementTypes } from 'ts-common/src/model/reactflow'; +import { type BackendQueryFormat } from 'ts-common'; +import { MLTypesEnum } from 'ts-common/src/model/query/machineLearningModel'; +import { type QueryBuilderSettings } from 'ts-common/src/model/query/queryBuilderModel'; + +export function createQueryMultiGraphFromData(nodesData: any[], edgesData: any[]): QueryMultiGraph { + const nodes: SerializedNode<QueryGraphNodes>[] = nodesData.map(node => ({ + key: node.id, + attributes: { + id: node.id, + name: node.name, + schemaKey: node.schemaKey, + type: node.type, + width: node.width, + height: node.height, + x: node.x, + y: node.y, + attributes: node.attributes.map((attribute: any) => ({ + handleData: { + nodeId: node.id, + nodeName: node.name, + nodeType: node.type, + handleType: 'entityAttributeHandle' as Handles, // check if different reactflow Handles + attributeName: attribute.name, + attributeType: attribute.type, + }, + })), + leftRelationHandleId: { + nodeId: node.id, + nodeName: node.name, + nodeType: node.type, + handleType: 'entityLeftHandle' as Handles, + }, + rightRelationHandleId: { + nodeId: node.id, + nodeName: node.name, + nodeType: node.type, + handleType: 'entityRightHandle' as Handles, + }, + selected: node.selected || false, + }, + })); + + const edges: SerializedEdge<QueryGraphEdges>[] = edgesData.map(edge => ({ + source: edge.sourceNodeId, + target: edge.targetNodeId, + type: edge.type, + sourceHandleData: { + nodeId: edge.sourceNodeId, + nodeName: edge.sourceNodeName, + nodeType: edge.sourceNodeType, + handleType: edge.sourceHandleType, + attributeName: edge.sourceAttributeName, + attributeType: edge.sourceAttributeType, + }, + targetHandleData: { + nodeId: edge.targetNodeId, + nodeName: edge.targetNodeName, + nodeType: edge.targetNodeType, + handleType: edge.targetHandleType, + attributeName: edge.targetAttributeName, + attributeType: edge.targetAttributeType, + }, + })); + + return { + nodes: nodes, + edges: edges, + options: { + type: 'mixed', + multi: true, + allowSelfLoops: false, + }, + attributes: {}, + }; +} + +export const ss_id: string = 'test'; +export const settingsBase: QueryBuilderSettings = { + depth: { + max: 1, + min: 1, + }, + limit: 500, + layout: 'manual', + unionTypes: {}, + autocompleteRelation: true, +}; + +export const visualQuery_1: QueryMultiGraph = { + edges: [ + { + key: 'geid_183_0', + source: 'id_1741246511422', + target: 'id_1741246512287', + attributes: { + type: 'connection', + sourceHandleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + attributeName: '', + }, + targetHandleData: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationLeft, + attributeName: '', + }, + }, + }, + { + key: 'geid_183_1', + source: 'id_1741246512287', + target: 'id_1741246511585', + attributes: { + type: 'connection', + sourceHandleData: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationRight, + attributeName: '', + }, + targetHandleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + attributeName: '', + }, + }, + }, + { + key: 'geid_262_0', + source: 'id_1741246511422', + target: 'id_1741246625352', + attributes: { + type: 'connection', + sourceHandleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + attributeName: '', + }, + targetHandleData: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationLeft, + attributeName: '', + }, + }, + }, + { + key: 'geid_319_0', + source: 'id_1741246625352', + target: 'id_1741246630119', + attributes: { + type: 'connection', + sourceHandleData: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationRight, + attributeName: '', + }, + targetHandleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + attributeName: '', + }, + }, + }, + ], + nodes: [ + { + key: 'id_1741246512287', + attributes: { + x: 180, + y: 90, + id: 'id_1741246512287', + name: 'WROTE', + type: QueryElementTypes.Relation, + depth: { + max: 1, + min: 1, + }, + width: 86.15010000000001, + height: 20, + schemaKey: 'WROTE_PersonMovie', + attributes: [ + { + handleData: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + ], + collection: 'WROTE', + leftEntityHandleId: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationLeft, + }, + rightEntityHandleId: { + nodeId: 'id_1741246512287', + nodeName: 'WROTE', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationRight, + }, + }, + }, + { + key: 'id_1741246511422', + attributes: { + x: 0, + y: 90, + id: 'id_1741246511422', + name: 'Person', + type: QueryElementTypes.Entity, + width: 78.2166, + height: 20, + schemaKey: 'Person', + attributes: [ + { + handleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + { + handleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'name', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'born', + attributeType: 'int', + }, + }, + ], + leftRelationHandleId: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + }, + rightRelationHandleId: { + nodeId: 'id_1741246511422', + nodeName: 'Person', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + }, + }, + }, + { + key: 'id_1741246511585', + attributes: { + x: 430, + y: 90, + id: 'id_1741246511585', + name: 'Movie', + type: QueryElementTypes.Entity, + width: 72.1999, + height: 20, + schemaKey: 'Movie', + attributes: [ + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.RelationAttribute, + attributeName: 'tagline', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'votes', + attributeType: 'int', + }, + }, + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'title', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'released', + attributeType: 'int', + }, + }, + ], + leftRelationHandleId: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + }, + rightRelationHandleId: { + nodeId: 'id_1741246511585', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + }, + }, + }, + { + key: 'id_1741246625352', + attributes: { + x: 180, + y: 170, + id: 'id_1741246625352', + name: 'PRODUCED', + type: QueryElementTypes.Relation, + depth: { + max: 1, + min: 1, + }, + width: 104.2002, + height: 20, + schemaKey: 'PRODUCED_PersonMovie', + attributes: [ + { + handleData: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + ], + collection: 'PRODUCED', + leftEntityHandleId: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationLeft, + }, + rightEntityHandleId: { + nodeId: 'id_1741246625352', + nodeName: 'PRODUCED', + nodeType: QueryElementTypes.Relation, + handleType: Handles.RelationRight, + }, + }, + }, + { + key: 'id_1741246630119', + attributes: { + x: 390, + y: 200, + id: 'id_1741246630119', + name: 'Movie', + type: QueryElementTypes.Entity, + width: 72.1999, + height: 20, + schemaKey: 'Movie', + attributes: [ + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: '(# Connection)', + attributeType: 'float', + }, + }, + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.RelationAttribute, + attributeName: 'tagline', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.RelationAttribute, + attributeName: 'votes', + attributeType: 'int', + }, + }, + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityAttribute, + attributeName: 'title', + attributeType: 'string', + }, + }, + { + handleData: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.RelationAttribute, + attributeName: 'released', + attributeType: 'int', + }, + }, + ], + leftRelationHandleId: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityLeft, + }, + rightRelationHandleId: { + nodeId: 'id_1741246630119', + nodeName: 'Movie', + nodeType: QueryElementTypes.Entity, + handleType: Handles.EntityRight, + }, + }, + }, + ], + options: { + type: 'mixed', + multi: true, + allowSelfLoops: true, + }, + attributes: {}, +}; +export const expectedResult_1: BackendQueryFormat = { + saveStateID: 'test', + return: ['*'], + query: [ + { + id: 'path_0', + node: { + id: 'id_1741246511422', + label: 'Person', + relation: { + id: 'id_1741246512287', + label: 'WROTE', + depth: { + max: 1, + min: 1, + }, + direction: 'BOTH', + node: { + id: 'id_1741246511585', + label: 'Movie', + }, + }, + }, + }, + { + id: 'path_1', + node: { + id: 'id_1741246511422', + label: 'Person', + relation: { + id: 'id_1741246625352', + label: 'PRODUCED', + depth: { + max: 1, + min: 1, + }, + direction: 'BOTH', + node: { + id: 'id_1741246630119', + label: 'Movie', + }, + }, + }, + }, + ], + machineLearning: [ + { type: MLTypesEnum.linkPrediction, parameters: [], id: 1 }, + { type: MLTypesEnum.centrality, parameters: [], id: 2 }, + { type: MLTypesEnum.communityDetection, parameters: [], id: 3 }, + { type: MLTypesEnum.shortestPath, parameters: [], id: 4 }, + ], + limit: 500, + cached: false, + logic: undefined, +}; diff --git a/src/utils/insights/validateInsight.ts b/src/utils/insights/validateInsight.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0827ab1372659af922440011959fa9b297ec4ea --- /dev/null +++ b/src/utils/insights/validateInsight.ts @@ -0,0 +1,28 @@ +import { type InsightModel, isValidEmail, isValidCronExpression } from 'ts-common'; +import { log } from '../../logger'; + +export function validateInsight(insight: InsightModel, force: boolean) { + if (!insight || insight.userId == null || !insight.template || !insight.userId || !insight.saveStateId) { + log.error('Invalid Insight received in insightProcessorConsumer:', insight); + return false; + } + + if (!insight.recipients || insight.recipients.length === 0) { + log.error('No recipients found in the insight, skipping'); + return false; + } + + for (const recipient of insight.recipients) { + if (!isValidEmail(recipient)) { + log.error(`Invalid email for recipient: ${recipient}`); + return false; + } + } + + if (!insight.frequency || !isValidCronExpression(insight.frequency)) { + log.error('No frequency found in the insight, skipping'); + return false; + } + + return true; +} diff --git a/src/utils/insights/validateTypeInsight.ts b/src/utils/insights/validateTypeInsight.ts new file mode 100644 index 0000000000000000000000000000000000000000..27482f672f9d242532a12a6940620b516e362691 --- /dev/null +++ b/src/utils/insights/validateTypeInsight.ts @@ -0,0 +1,46 @@ +import { type InsightModel } from 'ts-common'; +import { log } from '../../logger'; + +export function validateTypeInsight(insight: InsightModel, force: boolean) { + switch (insight.alarmMode) { + case 'disabled': + if (!force) { + log.debug('Alarm mode is disabled', insight.id); + return false; + } else { + return true; + } + + case 'conditional': + if (!insight.conditionsCheck || insight.conditionsCheck.length === 0) { + log.error('No conditions found in Insight conditionsCheck'); + return false; + } + for (const condition of insight.conditionsCheck) { + // TODO! remove property from insight conditional model + if (!condition.nodeLabel || !condition.statistic || !condition.operator || condition.value === undefined) { + log.error('Invalid condition found:', condition); + return false; + } + } + return true; + + case 'diff': + if (!insight.frequency) { + log.error('No frequency found in Insight frequency: ', insight.frequency); + return true; + } else { + return true; + } + case 'always': + if (!insight.frequency) { + log.error('No frequency found in Insight frequency: ', insight.frequency); + return false; + } else { + return true; + } + + default: + return true; + } +} diff --git a/src/utils/queryPublisher.ts b/src/utils/queryPublisher.ts index d10bea719a1387114ca8c66ff08a5f42bc2b0930..ca17e2f169c136a5f7f73df401a68fe086749eb0 100644 --- a/src/utils/queryPublisher.ts +++ b/src/utils/queryPublisher.ts @@ -6,19 +6,35 @@ import type { RabbitMqBroker } from 'ts-common/rabbitMq'; export class QueryPublisher { private frontendPublisher: RabbitMqBroker; private mlPublisher: RabbitMqBroker; - private routingKey: string; - private headers: BackendMessageHeader; - private queryID: number; + private routingKey?: string; + private headers?: BackendMessageHeader; + private queryID?: string; - constructor(frontendPublisher: RabbitMqBroker, mlPublisher: RabbitMqBroker, headers: BackendMessageHeader, queryID: number) { + constructor(frontendPublisher: RabbitMqBroker, mlPublisher: RabbitMqBroker) { this.frontendPublisher = frontendPublisher; this.mlPublisher = mlPublisher; + } + + withHeaders(headers?: BackendMessageHeader) { this.headers = headers; - this.routingKey = headers.routingKey; + return this; + } + + withRoutingKey(routingKey?: string) { + this.routingKey = routingKey; + return this; + } + + withQueryID(queryID?: string) { this.queryID = queryID; + return this; } publishStatusToFrontend(status: string) { + if (!this.headers || !this.routingKey || !this.queryID) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + this.frontendPublisher.publishMessageToFrontend( { type: wsReturnKey.queryStatusUpdate, @@ -32,6 +48,10 @@ export class QueryPublisher { } publishErrorToFrontend(reason: string) { + if (!this.headers || !this.routingKey || !this.queryID) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + this.frontendPublisher.publishMessageToFrontend( { type: wsReturnKey.queryStatusError, @@ -45,13 +65,17 @@ export class QueryPublisher { } publishTranslationResultToFrontend(query: string) { + if (!this.headers || !this.routingKey || !this.queryID) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + this.frontendPublisher.publishMessageToFrontend( { type: wsReturnKey.queryStatusTranslationResult, callID: this.headers.callID, value: { result: query, - queryID: this.queryID, + queryID: this.headers.callID, }, status: 'success', }, @@ -61,6 +85,10 @@ export class QueryPublisher { } publishResultToFrontend(result: GraphQueryResultMetaFromBackend) { + if (!this.headers || !this.routingKey || !this.queryID) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + this.frontendPublisher.publishMessageToFrontend( { type: wsReturnKey.queryStatusResult, @@ -70,7 +98,7 @@ export class QueryPublisher { type: 'nodelink', payload: result, }, - queryID: this.queryID, + queryID: this.headers.callID, }, status: 'success', }, @@ -80,6 +108,10 @@ export class QueryPublisher { } publishMachineLearningRequest(result: GraphQueryResultFromBackend, mlAttributes: MachineLearning, headers: BackendMessageHeader) { + if (!this.headers || !this.routingKey) { + throw new Error('Headers or RoutingKey or queryID not set'); + } + // FIXME: Change ML to use the same message format that the frontend uses const toMlResult = { nodes: result.nodes.map(node => ({ ...node, id: node._id })),