diff --git a/src/commands/command-executor.js b/src/commands/command-executor.js index 62d723e178..1abef9752a 100644 --- a/src/commands/command-executor.js +++ b/src/commands/command-executor.js @@ -5,19 +5,6 @@ const { forEach } = require('p-iteration'); const Command = require('./command'); const constants = require('../constants/constants'); -/** - * Command statuses - * @type {{failed: string, expired: string, started: string, pending: string, completed: string}} - */ -const STATUS = { - failed: 'FAILED', - expired: 'EXPIRED', - started: 'STARTED', - pending: 'PENDING', - completed: 'COMPLETED', - repeating: 'REPEATING', -}; - /** * How many commands will run in parallel * @type {number} @@ -106,7 +93,7 @@ class CommandExecutor { if (command.deadline_at && now > command.deadline_at) { this.logger.warn(`Command ${command.name} and ID ${command.id} is too late...`); await this._update(command, { - status: STATUS.expired, + status: constants.COMMAND_STATUS.EXPIRED, }); try { const result = await handler.expired(command); @@ -137,7 +124,7 @@ class CommandExecutor { await this._update( command, { - status: STATUS.started, + status: constants.COMMAND_STATUS.STARTED, }, transaction, ); @@ -148,7 +135,7 @@ class CommandExecutor { await this._update( command, { - status: STATUS.repeating, + status: constants.COMMAND_STATUS.REPEATING, }, transaction, ); @@ -179,7 +166,7 @@ class CommandExecutor { await this._update( command, { - status: STATUS.completed, + status: constants.COMMAND_STATUS.COMPLETED, }, transaction, ); @@ -293,7 +280,7 @@ class CommandExecutor { if (command.retries > 1) { command.data = handler.pack(command.data); await this._update(command, { - status: STATUS.pending, + status: constants.COMMAND_STATUS.PENDING, retries: command.retries - 1, }); const period = command.period ? command.period : 0; @@ -322,7 +309,7 @@ class CommandExecutor { } else { try { await this._update(command, { - status: STATUS.failed, + status: constants.COMMAND_STATUS.FAILED, message: err.message, }); this.logger.warn(`Error in command: ${command.name}, error: ${err.message}`); @@ -359,7 +346,7 @@ class CommandExecutor { const commandInstance = this.commandResolver.resolve(command.name); command.data = commandInstance.pack(command.data); } - command.status = STATUS.pending; + command.status = constants.COMMAND_STATUS.PENDING; const opts = {}; if (transaction != null) { opts.transaction = transaction; @@ -412,7 +399,11 @@ class CommandExecutor { this.logger.info('Replay pending/started commands from the database...'); const pendingCommands = ( await this.repositoryModuleManager.getCommandsWithStatus( - [STATUS.pending, STATUS.started, STATUS.repeating], + [ + constants.COMMAND_STATUS.PENDING, + constants.COMMAND_STATUS.STARTED, + constants.COMMAND_STATUS.REPEATING, + ], ['cleanerCommand', 'autoupdaterCommand'], ) ).filter((command) => !constants.PERMANENT_COMMANDS.includes(command.name)); diff --git a/src/commands/common/commands-cleaner-command.js b/src/commands/common/commands-cleaner-command.js new file mode 100644 index 0000000000..d6326d2440 --- /dev/null +++ b/src/commands/common/commands-cleaner-command.js @@ -0,0 +1,57 @@ +const Command = require('../command'); +const { + COMMAND_STATUS, + FINALIZED_COMMAND_CLEANUP_TIME_MILLS, +} = require('../../constants/constants'); + +/** + * Increases approval for Bidding contract on blockchain + */ +class CommandsCleanerCommand extends Command { + constructor(ctx) { + super(ctx); + this.logger = ctx.logger; + this.repositoryModuleManager = ctx.repositoryModuleManager; + } + + /** + * Executes command and produces one or more events + * @param command + */ + async execute() { + await this.repositoryModuleManager.removeFinalizedCommands([ + COMMAND_STATUS.COMPLETED, + COMMAND_STATUS.FAILED, + COMMAND_STATUS.EXPIRED, + ]); + return Command.repeat(); + } + + /** + * Recover system from failure + * @param command + * @param error + */ + async recover(command, error) { + this.logger.warn(`Failed to clean finalized commands: error: ${error.message}`); + return Command.repeat(); + } + + /** + * Builds default command + * @param map + * @returns {{add, data: *, delay: *, deadline: *}} + */ + default(map) { + const command = { + name: 'commandsCleanerCommand', + data: {}, + period: FINALIZED_COMMAND_CLEANUP_TIME_MILLS, + transactional: false, + }; + Object.assign(command, map); + return command; + } +} + +module.exports = CommandsCleanerCommand; diff --git a/src/commands/common/operation-id-cleaner-command.js b/src/commands/common/operation-id-cleaner-command.js new file mode 100644 index 0000000000..beb74a2d15 --- /dev/null +++ b/src/commands/common/operation-id-cleaner-command.js @@ -0,0 +1,54 @@ +const Command = require('../command'); +const constants = require('../../constants/constants'); + +/** + * Increases approval for Bidding contract on blockchain + */ +class OperationIdCleanerCommand extends Command { + constructor(ctx) { + super(ctx); + this.logger = ctx.logger; + this.repositoryModuleManager = ctx.repositoryModuleManager; + } + + /** + * Executes command and produces one or more events + * @param command + */ + async execute() { + const timeToBeDeleted = Date.now() - constants.OPERATION_ID_COMMAND_CLEANUP_TIME_MILLS; + await this.repositoryModuleManager.removeOperationIdRecord(timeToBeDeleted, [ + constants.OPERATION_ID_STATUS.COMPLETED, + constants.OPERATION_ID_STATUS.FAILED, + ]); + return Command.repeat(); + } + + /** + * Recover system from failure + * @param command + * @param error + */ + async recover(command, error) { + this.logger.warn(`Failed to clean operation ids table: error: ${error.message}`); + return Command.repeat(); + } + + /** + * Builds default command + * @param map + * @returns {{add, data: *, delay: *, deadline: *}} + */ + default(map) { + const command = { + name: 'operationIdCleanerCommand', + period: constants.OPERATION_ID_COMMAND_CLEANUP_TIME_MILLS, + data: {}, + transactional: false, + }; + Object.assign(command, map); + return command; + } +} + +module.exports = OperationIdCleanerCommand; diff --git a/src/commands/query/query-command.js b/src/commands/query/query-command.js new file mode 100644 index 0000000000..8e7f54eb28 --- /dev/null +++ b/src/commands/query/query-command.js @@ -0,0 +1,59 @@ +const Command = require('../command'); +const { OPERATION_ID_STATUS, ERROR_TYPE } = require('../../constants/constants'); + +class QueryCommand extends Command { + constructor(ctx) { + super(ctx); + this.queryService = ctx.queryService; + + this.errorType = ERROR_TYPE.QUERY.LOCAL_QUERY_ERROR; + } + + async execute(command) { + const { query, queryType, operationId } = command.data; + + let data; + + await this.operationIdService.updateOperationIdStatus( + operationId, + OPERATION_ID_STATUS.QUERY.QUERY_START, + ); + try { + data = await this.queryService.query(query, queryType); + + await this.operationIdService.updateOperationIdStatus( + operationId, + OPERATION_ID_STATUS.QUERY.QUERY_END, + ); + + await this.operationIdService.cacheOperationIdData(operationId, data); + + await this.operationIdService.updateOperationIdStatus( + operationId, + OPERATION_ID_STATUS.COMPLETED, + ); + } catch (e) { + await this.handleError(operationId, e.message, this.errorType, true); + } + + return Command.empty(); + } + + /** + * Builds default getInitCommand + * @param map + * @returns {{add, data: *, delay: *, deadline: *}} + */ + default(map) { + const command = { + name: 'queryCommand', + delay: 0, + retries: 0, + transactional: false, + }; + Object.assign(command, map); + return command; + } +} + +module.exports = QueryCommand; diff --git a/src/constants/constants.js b/src/constants/constants.js index 570da8f971..fc1b447c66 100644 --- a/src/constants/constants.js +++ b/src/constants/constants.js @@ -79,7 +79,12 @@ exports.OPERATION_IDS_COMMAND_CLEANUP_TIME_MILLS = 24 * 60 * 60 * 1000; /** * @constant {Array} PERMANENT_COMMANDS - List of all permanent commands */ -exports.PERMANENT_COMMANDS = ['otnodeUpdateCommand', 'sendTelemetryCommand']; +exports.PERMANENT_COMMANDS = [ + 'otnodeUpdateCommand', + 'sendTelemetryCommand', + 'operationIdCleanerCommand', + 'commandsCleanerCommand', +]; /** * @constant {number} MAX_COMMAND_DELAY_IN_MILLS - Maximum delay for commands @@ -173,6 +178,9 @@ exports.ERROR_TYPE = { GET_REQUEST_REMOTE_ERROR: 'GetRequestRemoteError', GET_ERROR: 'GetError', }, + QUERY: { + LOCAL_QUERY_ERROR: 'LocalQueryError', + }, }; /** * @constant {object} OPERATION_ID_STATUS - @@ -226,6 +234,13 @@ exports.OPERATION_ID_STATUS = { VALIDATING_QUERY: 'VALIDATING_QUERY', SEARCHING_ENTITIES: 'SEARCHING_ENTITIES', }, + + QUERY: { + QUERY_INIT_START: 'QUERY_INIT_START', + QUERY_INIT_END: 'QUERY_INIT_END', + QUERY_START: 'QUERY_START', + QUERY_END: 'QUERY_END', + }, }; /** @@ -238,6 +253,29 @@ exports.OPERATIONS = { SEARCH: 'search', }; +/** + * @constant {number} OPERATION_ID_COMMAND_CLEANUP_TIME_MILLS - + * operation id command cleanup interval time 24h + */ +exports.OPERATION_ID_COMMAND_CLEANUP_TIME_MILLS = 24 * 60 * 60 * 1000; +/** + * @constant {number} FINALIZED_COMMAND_CLEANUP_TIME_MILLS - Command cleanup interval time + * finalized commands command cleanup interval time 24h + */ +exports.FINALIZED_COMMAND_CLEANUP_TIME_MILLS = 24 * 60 * 60 * 1000; +/** + * @constant {number} COMMAND_STATUS - + * Status for commands + */ +exports.COMMAND_STATUS = { + FAILED: 'FAILED', + EXPIRED: 'EXPIRED', + STARTED: 'STARTED', + PENDING: 'PENDING', + COMPLETED: 'COMPLETED', + REPEATING: 'REPEATING', +}; + /** * @constant {object} NETWORK_PROTOCOLS - * Network protocols @@ -295,3 +333,12 @@ exports.PUBLISH_METHOD = { PROVISION: 'PROVISION', UPDATE: 'UPDATE', }; + +/** + * Local query types + * @type {{CONSTRUCT: string, SELECT: string}} + */ +exports.QUERY_TYPES = { + SELECT: 'SELECT', + CONSTRUCT: 'CONSTRUCT', +}; diff --git a/src/controller/http-api-router.js b/src/controller/http-api-router.js index 78983e3abe..7eee3cafd7 100644 --- a/src/controller/http-api-router.js +++ b/src/controller/http-api-router.js @@ -27,6 +27,15 @@ class HttpApiRouter { }, { rateLimit: true, requestSchema: this.jsonSchemaService.publishSchema() }, ); + + this.httpClientModuleManager.post( + '/query', + (req, res) => { + this.searchController.handleHttpApiQueryRequest(req, res); + }, + { requestSchema: this.jsonSchemaService.querySchema() }, + ); + this.httpClientModuleManager.post( '/get', (req, res) => { diff --git a/src/controller/v1/request-schema/query-request.js b/src/controller/v1/request-schema/query-request.js new file mode 100644 index 0000000000..e054cd7d99 --- /dev/null +++ b/src/controller/v1/request-schema/query-request.js @@ -0,0 +1,14 @@ +const { QUERY_TYPES } = require('../../../constants/constants'); + +module.exports = () => ({ + type: 'object', + required: ['type', 'query'], + properties: { + type: { + enum: [QUERY_TYPES.CONSTRUCT, QUERY_TYPES.SELECT], + }, + query: { + type: 'string', + }, + }, +}); diff --git a/src/controller/v1/result-controller.js b/src/controller/v1/result-controller.js index 989b492168..e76a8f9830 100644 --- a/src/controller/v1/result-controller.js +++ b/src/controller/v1/result-controller.js @@ -1,7 +1,7 @@ const { OPERATION_ID_STATUS } = require('../../constants/constants'); const BaseController = require('./base-controller'); -const availableOperations = ['publish', 'get', 'assertions:search', 'entities:search']; +const availableOperations = ['publish', 'get', 'assertions:search', 'entities:search', 'query']; class ResultController extends BaseController { constructor(ctx) { @@ -41,13 +41,8 @@ class ResultController extends BaseController { case 'assertions:search': case 'entities:search': case 'get': - if (handlerRecord.status === OPERATION_ID_STATUS.COMPLETED) { - response.data = await this.operationIdService.getCachedOperationIdData( - operationId, - ); - } - break; case 'publish': + case 'query': if (handlerRecord.status === OPERATION_ID_STATUS.COMPLETED) { response.data = await this.operationIdService.getCachedOperationIdData( operationId, diff --git a/src/controller/v1/search-controller.js b/src/controller/v1/search-controller.js index 0b48b9317f..7b31929297 100644 --- a/src/controller/v1/search-controller.js +++ b/src/controller/v1/search-controller.js @@ -12,6 +12,7 @@ class SearchController extends BaseController { this.fileService = ctx.fileService; this.commandExecutor = ctx.commandExecutor; this.operationIdService = ctx.operationIdService; + this.queryService = ctx.queryService; } async handleHttpApiSearchAssertionsRequest(req, res) { @@ -127,7 +128,32 @@ class SearchController extends BaseController { } } - handleHttpApiQueryRequest() {} + async handleHttpApiQueryRequest(req, res) { + const { query, type: queryType } = req.body; + + const operationId = await this.operationIdService.generateOperationId( + OPERATION_ID_STATUS.QUERY.QUERY_INIT_START, + ); + + this.returnResponse(res, 202, { + operationId, + }); + + await this.operationIdService.updateOperationIdStatus( + operationId, + OPERATION_ID_STATUS.QUERY.QUERY_INIT_END, + ); + + const commandSequence = ['queryCommand']; + + await this.commandExecutor.add({ + name: commandSequence[0], + sequence: commandSequence.slice(1), + delay: 0, + data: { query, queryType, operationId }, + transactional: false, + }); + } handleHttpApiProofsRequest() {} diff --git a/src/modules/repository/implementation/sequelize/sequelize-repository.js b/src/modules/repository/implementation/sequelize/sequelize-repository.js index f9038100bf..6566f47cd4 100644 --- a/src/modules/repository/implementation/sequelize/sequelize-repository.js +++ b/src/modules/repository/implementation/sequelize/sequelize-repository.js @@ -141,6 +141,15 @@ class SequelizeRepository { }); } + async removeFinalizedCommands(finalizedStatuses) { + await this.models.commands.destroy({ + where: { + status: { [Sequelize.Op.in]: finalizedStatuses }, + started_at: { [Sequelize.Op.lte]: Date.now() }, + }, + }); + } + // OPERATION_ID async createOperationIdRecord(handlerData) { const handlerRecord = await this.models.operation_ids.create(handlerData); @@ -164,6 +173,15 @@ class SequelizeRepository { }); } + async removeOperationIdRecord(timeToBeDeleted, statuses) { + await this.models.operation_ids.destroy({ + where: { + timestamp: { [Sequelize.Op.lt]: timeToBeDeleted }, + status: { [Sequelize.Op.in]: statuses }, + }, + }); + } + async getNumberOfNodesFoundForPublish(publishId) { return this.models.publish.findOne({ attributes: ['nodes_found'], diff --git a/src/modules/repository/repository-module-manager.js b/src/modules/repository/repository-module-manager.js index 1c217d5412..77537eba47 100644 --- a/src/modules/repository/repository-module-manager.js +++ b/src/modules/repository/repository-module-manager.js @@ -45,6 +45,12 @@ class RepositoryModuleManager extends BaseModuleManager { } } + async removeFinalizedCommands(finalizedStatuses) { + if (this.initialized) { + return this.getImplementation().module.removeFinalizedCommands(finalizedStatuses); + } + } + // OPERATION ID TABLE async createOperationIdRecord(handlerData) { if (this.initialized) { @@ -64,6 +70,15 @@ class RepositoryModuleManager extends BaseModuleManager { } } + async removeOperationIdRecord(timeToBeDeleted, statuses) { + if (this.initialized) { + return this.getImplementation().module.removeOperationIdRecord( + timeToBeDeleted, + statuses, + ); + } + } + // publish table async createOperationRecord(operation, operationId, status) { if (this.initialized) { diff --git a/src/modules/triple-store/implementation/ot-triple-store.js b/src/modules/triple-store/implementation/ot-triple-store.js index f31946b5ff..5048eb6015 100644 --- a/src/modules/triple-store/implementation/ot-triple-store.js +++ b/src/modules/triple-store/implementation/ot-triple-store.js @@ -2,6 +2,7 @@ const Engine = require('@comunica/query-sparql').QueryEngine; const { setTimeout } = require('timers/promises'); const { SCHEMA_CONTEXT } = require('../../../constants/constants'); const constants = require('./triple-store-constants'); +const { MEDIA_TYPES } = require('./triple-store-constants'); class OtTripleStore { async initialize(config, logger) { @@ -94,10 +95,18 @@ class OtTripleStore { } async construct(query) { - const result = await this.executeQuery(query); + const result = await this._executeQuery(query, MEDIA_TYPES.N_QUADS); return result; } + async select(query) { + // todo: add media type once bug is fixed + // no media type is passed because of comunica bug + // https://github.com/comunica/comunica/issues/1034 + const result = await this._executeQuery(query); + return JSON.parse(result); + } + async ask(query) { const result = await this.queryEngine.queryBoolean(query, this.queryContext); return result; @@ -130,28 +139,17 @@ class OtTripleStore { return true; } - async executeQuery(query) { + async _executeQuery(query, mediaType) { const result = await this.queryEngine.query(query, this.queryContext); - const { data } = await this.queryEngine.resultToString( - result, - 'application/n-quads', - this.queryContext, - ); - let nquads = ''; - for await (const nquad of data) { - nquads += nquad; - } - return nquads; - } + const { data } = await this.queryEngine.resultToString(result, mediaType); - async execute(query) { - const result = await this.queryEngine.query(query, this.queryContext); - const { data } = await this.queryEngine.resultToString(result); let response = ''; + for await (const chunk of data) { response += chunk; } - return JSON.parse(response); + + return response; } cleanEscapeCharacter(query) { diff --git a/src/modules/triple-store/implementation/triple-store-constants.js b/src/modules/triple-store/implementation/triple-store-constants.js index 0157173d3f..7ab6178f77 100644 --- a/src/modules/triple-store/implementation/triple-store-constants.js +++ b/src/modules/triple-store/implementation/triple-store-constants.js @@ -9,3 +9,31 @@ exports.TRIPLE_STORE_CONNECT_MAX_RETRIES = 10; * - Wait interval between retries for connecting to triple store */ exports.TRIPLE_STORE_CONNECT_RETRY_FREQUENCY = 10; // 10 seconds + +/** + * @constant {number} TRIPLE_STORE_QUEUE_LIMIT + * - Triple store queue limit + */ +exports.TRIPLE_STORE_QUEUE_LIMIT = 5000; + +/** + * Triple store media types + * @type {{APPLICATION_JSON: string, N_QUADS: string, SPARQL_RESULTS_JSON: string, LD_JSON: string}} + */ +exports.MEDIA_TYPES = { + LD_JSON: 'application/ld+json', + N_QUADS: 'application/n-quads', + SPARQL_RESULTS_JSON: 'application/sparql-results+json', +}; + +/** + * XML data types + * @type {{FLOAT: string, DECIMAL: string, DOUBLE: string, BOOLEAN: string, INTEGER: string}} + */ +exports.XML_DATA_TYPES = { + DECIMAL: 'http://www.w3.org/2001/XMLSchema#decimal', + FLOAT: 'http://www.w3.org/2001/XMLSchema#float', + DOUBLE: 'http://www.w3.org/2001/XMLSchema#double', + INTEGER: 'http://www.w3.org/2001/XMLSchema#integer', + BOOLEAN: 'http://www.w3.org/2001/XMLSchema#boolean', +}; diff --git a/src/modules/triple-store/triple-store-module-manager.js b/src/modules/triple-store/triple-store-module-manager.js index 226831c1c7..1e021d6bab 100644 --- a/src/modules/triple-store/triple-store-module-manager.js +++ b/src/modules/triple-store/triple-store-module-manager.js @@ -57,15 +57,15 @@ class TripleStoreModuleManager extends BaseModuleManager { } } - async findAssertions(nquads) { + async select(query) { if (this.initialized) { - return this.getImplementation().module.findAssertions(nquads); + return this.getImplementation().module.select(query); } } - async select(query) { + async findAssertions(nquads) { if (this.initialized) { - return this.getImplementation().module.select(query); + return this.getImplementation().module.findAssertions(nquads); } } diff --git a/src/service/data-service.js b/src/service/data-service.js index d750bbf9bf..dfa838f061 100644 --- a/src/service/data-service.js +++ b/src/service/data-service.js @@ -1,8 +1,12 @@ const jsonld = require('jsonld'); + const { SCHEMA_CONTEXT } = require('../constants/constants'); +const { + MEDIA_TYPES, + XML_DATA_TYPES, +} = require('../modules/triple-store/implementation/triple-store-constants'); const ALGORITHM = 'URDNA2015'; -const FORMAT = 'application/n-quads'; class DataService { constructor(ctx) { @@ -13,7 +17,7 @@ class DataService { async toNQuads(content, inputFormat) { const options = { algorithm: ALGORITHM, - format: FORMAT, + format: MEDIA_TYPES.N_QUADS, }; if (inputFormat) { @@ -41,6 +45,48 @@ class DataService { return nquads; } + + /** + * Returns bindings with proper data types + * @param bindings + * @returns {*[]} + */ + parseBindings(bindings) { + const result = []; + + for (const row of bindings) { + const obj = {}; + for (const columnName in row) { + obj[columnName] = this._parseBindingDataTypes(row[columnName]); + } + result.push(obj); + } + + return result; + } + + /** + * Returns cast binding value based on datatype + * @param data + * @returns {boolean|number|string} + * @private + */ + _parseBindingDataTypes(data) { + const [value, dataType] = data.split('^^'); + + switch (dataType) { + case XML_DATA_TYPES.DECIMAL: + case XML_DATA_TYPES.FLOAT: + case XML_DATA_TYPES.DOUBLE: + return parseFloat(JSON.parse(value)); + case XML_DATA_TYPES.INTEGER: + return parseInt(JSON.parse(value), 10); + case XML_DATA_TYPES.BOOLEAN: + return JSON.parse(value) === 'true'; + default: + return value; + } + } } module.exports = DataService; diff --git a/src/service/json-schema-service.js b/src/service/json-schema-service.js index 3720f21bd2..eb985644f5 100644 --- a/src/service/json-schema-service.js +++ b/src/service/json-schema-service.js @@ -1,6 +1,7 @@ const publishSchema = require('../controller/v1/request-schema/publish-schema'); const getSchema = require('../controller/v1/request-schema/get-schema'); const searchSchema = require('../controller/v1/request-schema/search-schema'); +const querySchema = require('../controller/v1/request-schema/query-request'); class JsonSchemaService { constructor(ctx) { @@ -18,6 +19,10 @@ class JsonSchemaService { searchSchema() { return searchSchema(); } + + querySchema() { + return querySchema(); + } } module.exports = JsonSchemaService; diff --git a/src/service/query-service.js b/src/service/query-service.js new file mode 100644 index 0000000000..7e08039fb2 --- /dev/null +++ b/src/service/query-service.js @@ -0,0 +1,30 @@ +const { QUERY_TYPES } = require('../constants/constants'); + +class QueryService { + constructor(ctx) { + this.tripleStoreModuleManager = ctx.tripleStoreModuleManager; + this.dataService = ctx.dataService; + } + + async query(query, queryType) { + switch (queryType) { + case QUERY_TYPES.CONSTRUCT: + return this.constructQuery(query); + case QUERY_TYPES.SELECT: + return this.selectQuery(query); + default: + throw new Error(`Unknown query type ${queryType}`); + } + } + + constructQuery(query) { + return this.tripleStoreModuleManager.construct(query); + } + + async selectQuery(query) { + const bindings = await this.tripleStoreModuleManager.select(query); + return this.dataService.parseBindings(bindings); + } +} + +module.exports = QueryService; diff --git a/test/bdd/features/publish-errors.feature b/test/bdd/features/publish-errors.feature index 1dea630a26..4b7cc2e44d 100644 --- a/test/bdd/features/publish-errors.feature +++ b/test/bdd/features/publish-errors.feature @@ -11,6 +11,7 @@ Feature: Publish errors test # When I call publish on node 1 with validAssertion # And Last publish finished with status: PublishStartError + @publish-errors Scenario: Node is not able to validate assertion on the network Given I setup 4 nodes