refs #5036 loggable refactored, tests fixed
gitea/salix/pipeline/head There was a failure building this commit Details

This commit is contained in:
Alexandre Riera 2023-01-26 15:49:30 +01:00
parent 28de2e8ba4
commit d69b204c4c
8 changed files with 364 additions and 450 deletions

View File

@ -523,7 +523,7 @@ export default {
},
itemLog: {
anyLineCreated: 'vn-item-log > vn-log vn-tbody > vn-tr',
fifthLineCreatedProperty: 'vn-item-log > vn-log vn-tbody > vn-tr:nth-child(5) table tr:nth-child(2) td.after',
fifthLineCreatedProperty: 'vn-item-log > vn-log vn-tbody > vn-tr:nth-child(5) table tr:nth-child(4) td.after',
},
ticketSummary: {
header: 'vn-ticket-summary > vn-card > h5',

View File

@ -59,6 +59,6 @@ describe('Item log path', () => {
const fifthLineCreatedProperty = await page
.waitToGetProperty(selectors.itemLog.fifthLineCreatedProperty, 'innerText');
expect(fifthLineCreatedProperty).toEqual('Coral y materiales similares');
expect(fifthLineCreatedProperty).toEqual('05080000');
});
});

View File

@ -37,6 +37,6 @@ describe('Zone descriptor path', () => {
await page.accessToSection('ticket.card.log');
const lastChanges = await page.waitToGetProperty(selectors.ticketLog.changes, 'innerText');
expect(lastChanges).toContain('Arreglar');
expect(lastChanges).toContain('1');
});
});

View File

@ -1,4 +1,3 @@
const pick = require('object.pick');
const LoopBackContext = require('loopback-context');
module.exports = function(Self) {
@ -7,421 +6,10 @@ module.exports = function(Self) {
};
Self.observe('before save', async function(ctx) {
const models = ctx.Model.app.models;
const definition = ctx.Model.definition;
const Model = models[definition.name];
let opts = ctx.options;
if (!opts) opts = ctx.options = {};
// await txBegin(ctx);
// try {
// await grabUserLog(ctx, 'login');
let oldInstance;
let newInstance;
if (ctx.data) {
const changes = pick(ctx.currentInstance, Object.keys(ctx.data));
newInstance = ctx.data;
oldInstance = changes;
if (ctx.where && !ctx.currentInstance) {
const fields = Object.keys(ctx.data);
ctx.oldInstances = await Model.find({
where: ctx.where,
fields: fields
}, opts);
}
}
// Get changes from created instance
if (ctx.isNewInstance)
newInstance = ctx.instance.__data;
ctx.hookState.oldInstance = oldInstance;
ctx.hookState.newInstance = newInstance;
// } catch (e) {
// await txEnd(ctx, true);
// }
});
Self.observe('after save', async function(ctx) {
const loopBackContext = LoopBackContext.getCurrentContext();
const models = ctx.Model.app.models;
const definition = ctx.Model.definition;
const settings = ctx.Model.definition.settings;
const relations = ctx.Model.relations;
let opts = ctx.options;
if (!opts) opts = ctx.options = {};
// try {
let primaryKey;
for (let property in definition.properties) {
if (definition.properties[property].id) {
primaryKey = property;
break;
}
}
if (!primaryKey) throw new Error('Primary key not found');
let originId;
// RELATIONS LOG
let changedModelId;
if (ctx.instance && !settings.log.relation) {
originId = ctx.instance.id;
changedModelId = ctx.instance.id;
} else if (settings.log.relation) {
primaryKey = relations[settings.log.relation].keyFrom;
if (ctx.where && ctx.where[primaryKey])
originId = ctx.where[primaryKey];
else if (ctx.instance) {
originId = ctx.instance[primaryKey];
changedModelId = ctx.instance.id;
}
} else {
originId = ctx.currentInstance.id;
changedModelId = ctx.currentInstance.id;
}
// Sets the changedModelValue to save and the instances changed in case its an updateAll
let showField = settings.log.showField;
let where;
if (showField && (!ctx.instance || !ctx.instance[showField]) && ctx.where) {
changedModelId = [];
where = [];
let changedInstances = await models[definition.name].find({
where: ctx.where,
fields: ['id', showField, primaryKey]
}, opts);
changedInstances.forEach(element => {
where.push(element[showField]);
changedModelId.push(element.id);
originId = element[primaryKey];
});
} else if (ctx.hookState.oldInstance)
where = ctx.instance[showField];
// Set oldInstance, newInstance, userFk and action
let oldInstance = {};
if (ctx.hookState.oldInstance)
Object.assign(oldInstance, ctx.hookState.oldInstance);
let newInstance = {};
if (ctx.hookState.newInstance)
Object.assign(newInstance, ctx.hookState.newInstance);
let userFk;
if (loopBackContext)
userFk = loopBackContext.active.accessToken.userId;
const action = setActionType(ctx);
removeUnloggable(definition, oldInstance);
removeUnloggable(definition, newInstance);
oldInstance = await fkToValue(oldInstance, ctx);
newInstance = await fkToValue(newInstance, ctx);
// Prevent log with no new changes
const hasNewChanges = Object.keys(newInstance).length;
if (!hasNewChanges) return;
let logRecord = {
originFk: originId,
userFk: userFk,
action: action,
changedModel: definition.name,
changedModelId: changedModelId, // Model property with an different data type will throw a NaN error
changedModelValue: where,
oldInstance: oldInstance,
newInstance: newInstance
};
const logsToSave = setLogsToSave(where, changedModelId, logRecord, ctx);
const logModel = settings.log.model;
await models[logModel].create(logsToSave, opts);
// await grabUserLog(ctx, 'logout');
// } catch (e) {
// await txEnd(ctx, true);
// throw e;
// }
// await txEnd(ctx);
ctx.options.httpCtx = LoopBackContext.getCurrentContext();
});
Self.observe('before delete', async function(ctx) {
const models = ctx.Model.app.models;
const definition = ctx.Model.definition;
const relations = ctx.Model.relations;
let opts = ctx.options;
if (!opts) opts = ctx.options = {};
// await txBegin(ctx);
// try {
// await grabUserLog(ctx, 'login');
if (ctx.where) {
const affectedModel = definition.name;
let deletedInstances = await models[affectedModel].find({
where: ctx.where
}, opts);
const relation = definition.settings.log.relation;
if (relation) {
const primaryKey = relations[relation].keyFrom;
let arrangedDeletedInstances = [];
for (let i = 0; i < deletedInstances.length; i++) {
if (primaryKey)
deletedInstances[i].originFk = deletedInstances[i][primaryKey];
let arrangedInstance = await fkToValue(deletedInstances[i], ctx);
arrangedDeletedInstances[i] = arrangedInstance;
}
ctx.hookState.oldInstance = arrangedDeletedInstances;
}
}
// } catch (e) {
// await txEnd(ctx, true);
// }
ctx.options.httpCtx = LoopBackContext.getCurrentContext();
});
Self.observe('after delete', async function(ctx) {
const loopBackContext = LoopBackContext.getCurrentContext();
const models = ctx.Model.app.models;
const definition = ctx.Model.definition;
const settings = definition.settings;
let opts = ctx.options;
if (!opts) opts = ctx.options = {};
// try {
if (ctx.hookState.oldInstance) {
ctx.hookState.oldInstance.forEach(async instance => {
let userFk;
if (loopBackContext)
userFk = loopBackContext.active.accessToken.userId;
const changedModelValue = settings.log.changedModelValue;
const logRecord = {
originFk: instance.originFk,
userFk: userFk,
action: 'delete',
changedModel: definition.name,
changedModelId: instance.id,
changedModelValue: instance[changedModelValue],
oldInstance: instance,
newInstance: {}
};
delete instance.originFk;
const logModel = settings.log.model;
await models[logModel].create(logRecord, opts);
});
}
// await grabUserLog(ctx, 'logout');
// } catch (e) {
// await txEnd(ctx, true);
// throw e;
// }
// await txEnd(ctx);
});
// Get log values from a foreign key
async function fkToValue(instance, ctx) {
const models = ctx.Model.app.models;
const relations = ctx.Model.relations;
let opts = ctx.options;
if (!opts) opts = ctx.options = {};
const instanceCopy = JSON.parse(JSON.stringify(instance));
const result = {};
for (const key in instanceCopy) {
let value = instanceCopy[key];
if (value instanceof Object || value === undefined)
continue;
if (value) {
for (const relationName in relations) {
const relation = relations[relationName];
if (relation.keyFrom == key && key != 'id') {
const model = relation.modelTo;
const modelName = relation.modelTo.modelName;
const properties = model && model.definition.properties;
const settings = model && model.definition.settings;
const recordSet = await models[modelName].findById(value, null, opts);
const hasShowField = settings.log && settings.log.showField;
let showField = hasShowField && recordSet
&& recordSet[settings.log.showField];
if (!showField) {
const showFieldNames = [
'name',
'description',
'code',
'nickname'
];
for (field of showFieldNames) {
const propField = properties && properties[field];
const recordField = recordSet && recordSet[field];
if (propField && recordField) {
showField = field;
break;
}
}
}
if (showField && recordSet && recordSet[showField]) {
value = recordSet[showField];
break;
}
value = recordSet && recordSet.id || value;
break;
}
}
}
result[key] = value;
}
return result;
}
/**
* Removes unwanted properties
* @param {*} definition Model definition
* @param {*} properties Modified object properties
*/
function removeUnloggable(definition, properties) {
const objectCopy = Object.assign({}, properties);
const propList = Object.keys(objectCopy);
const propDefs = new Map();
for (let property in definition.properties) {
const propertyDef = definition.properties[property];
propDefs.set(property, propertyDef);
}
for (let property of propList) {
const propertyDef = propDefs.get(property);
const firstChar = property.substring(0, 1);
const isPrivate = firstChar == '$';
if (isPrivate || !propertyDef)
delete properties[property];
if (!propertyDef) continue;
if (propertyDef.log === false)
delete properties[property];
else if (propertyDef.logValue === false)
properties[property] = null;
}
}
// this function retuns all the instances changed in case this is an updateAll
function setLogsToSave(changedInstances, changedInstancesIds, logRecord, ctx) {
let promises = [];
if (changedInstances && typeof changedInstances == 'object') {
for (let i = 0; i < changedInstances.length; i++) {
logRecord.changedModelId = changedInstancesIds[i];
logRecord.changedModelValue = changedInstances[i];
if (ctx.oldInstances)
logRecord.oldInstance = ctx.oldInstances[i];
promises.push(JSON.parse(JSON.stringify(logRecord)));
}
} else
return logRecord;
return promises;
}
function setActionType(ctx) {
let oldInstance = ctx.hookState.oldInstance;
let newInstance = ctx.hookState.newInstance;
if (oldInstance && newInstance)
return 'update';
else if (!oldInstance && newInstance)
return 'insert';
return 'delete';
}
/**
* The functions txBegin and txEnd are used to transactionate the loggable.
* When a new transaction is created, they add a txLevel because in some cases
* the transactions are performed in a recursive way.
*
* The function grabUserLog check if the option grabUser is active in the Model and
* login or logout the user in the database depending on the action.
*
* Now they are commented out because the way to handle the errors
* that occur in the database that leave opened transactions has not been found.
* (https://redmine.verdnatura.es/issues/5036)
**/
// async function txBegin(ctx) {
// const opts = ctx.options || {};
// const models = ctx.Model.app.models;
// const definition = ctx.Model.definition;
// const Model = models[definition.name];
// if (opts.txLevel)
// opts.txLevel++;
// else if (!opts.transaction) {
// opts.txLevel = 1;
// opts.transaction = await Model.beginTransaction({});
// }
// }
// async function txEnd(ctx, undoChanges) {
// const opts = ctx.options || {};
// if (opts.txLevel) {
// opts.txLevel--;
// if (opts.txLevel === 0) {
// const tx = opts.transaction;
// delete opts.txLevel;
// delete opts.transaction;
// if (undoChanges)
// await tx.rollback();
// else
// await tx.commit();
// }
// }
// }
// async function grabUserLog(ctx, logAction) {
// const opts = ctx.options || {};
// const models = ctx.Model.app.models;
// const definition = ctx.Model.definition;
// const settings = definition.settings;
// const Model = models[definition.name];
// const hasGrabUser = settings.log && settings.log.grabUser;
// if (logAction === 'login' && hasGrabUser) {
// const loopBackContext = LoopBackContext.getCurrentContext();
// if (loopBackContext) {
// const userId = loopBackContext.active.accessToken.userId;
// const user = await models.Account.findById(userId, {fields: ['name']}, opts);
// await Model.rawSql(`CALL account.myUser_loginWithName(?)`, [user.name], opts);
// }
// } else if (logAction === 'logout' && hasGrabUser)
// await Model.rawSql(`CALL account.myUser_logout()`, null, opts);
// }
};

View File

@ -2,6 +2,7 @@ const mysql = require('mysql');
const ParameterizedSQL = require('loopback-connector').ParameterizedSQL;
const MySQL = require('loopback-connector-mysql').MySQL;
const EnumFactory = require('loopback-connector-mysql').EnumFactory;
const Transaction = require('loopback-connector').Transaction;
const fs = require('fs');
class VnMySQL extends MySQL {
@ -219,6 +220,315 @@ class VnMySQL extends MySQL {
this.makePagination(filter)
]);
}
create(model, data, opts, cb) {
const ctx = {data};
this.invokeMethod('create',
arguments, model, ctx, opts, cb);
}
createAll(model, data, opts, cb) {
const ctx = {data};
this.invokeMethod('createAll',
arguments, model, ctx, opts, cb);
}
save(model, data, opts, cb) {
const ctx = {data};
this.invokeMethod('save',
arguments, model, ctx, opts, cb);
}
updateOrCreate(model, data, opts, cb) {
const ctx = {data};
this.invokeMethod('updateOrCreate',
arguments, model, ctx, opts, cb);
}
replaceOrCreate(model, data, opts, cb) {
const ctx = {data};
this.invokeMethod('replaceOrCreate',
arguments, model, ctx, opts, cb);
}
destroyAll(model, where, opts, cb) {
const ctx = {where};
this.invokeMethod('destroyAll',
arguments, model, ctx, opts, cb);
}
update(model, where, data, opts, cb) {
const ctx = {where, data};
this.invokeMethod('update',
arguments, model, ctx, opts, cb);
}
replaceById(model, id, data, opts, cb) {
const ctx = {id, data};
this.invokeMethod('replaceById',
arguments, model, ctx, opts, cb);
}
isLoggable(model) {
const Model = this.getModelDefinition(model).model;
const settings = Model.definition.settings;
return settings.base && settings.base === 'Loggable';
}
invokeMethod(method, args, model, ctx, opts, cb) {
if (!this.isLoggable(model))
return super[method].apply(this, args);
this.invokeMethodP(method, [...args], model, ctx, opts)
.then(res => cb(...res), cb);
}
async invokeMethodP(method, args, model, ctx, opts) {
let tx;
if (!opts.transaction) {
tx = await Transaction.begin(this, {});
opts = Object.assign({transaction: tx, httpCtx: opts.httpCtx}, opts);
}
try {
// Fetch old values (update|delete) or login
await this.grabUserLog(model, opts, 'login');
let where = ctx.where;
const id = ctx.id;
const data = ctx.data;
const idName = this.idName(model);
const limitSet = new Set([
'save',
'updateOrCreate',
'replaceOrCreate',
'replaceById',
'updateAttributes',
'update'
]);
const limit = limitSet.has(method);
const opOpts = {
update: [
'update',
'replaceById',
'updateAttributes',
// |insert
'save',
'updateOrCreate',
'replaceOrCreate'
],
delete: [
'destroy',
'destroyAll'
],
insert: [
'create'
]
};
const opMap = new Map();
for (const op in opOpts) {
for (const met of opOpts[op])
opMap.set(met, op);
}
const op = opMap.get(method);
if (!where) {
if (id) where = {[idName]: id};
else where = {[idName]: data[idName]};
}
let oldInstances;
let newInstances;
// Fetch old values
switch (op) {
case 'update':
case 'delete':
// Single entity operation
const stmt = this.buildSelectStmt(op, data, idName, model, where, limit);
stmt.merge(`FOR UPDATE`);
oldInstances = await this.executeStmt(stmt, opts);
}
const res = await new Promise(resolve => {
const fnArgs = args.slice(0, -2);
fnArgs.push(opts);
fnArgs.push((...args) => resolve(args));
super[method].apply(this, fnArgs);
});
// Fetch new values
const ids = [];
switch (op) {
case 'insert':
case 'update': {
switch (method) {
case 'createAll':
for (const row of res[1])
ids.push(row[idName]);
break;
case 'create':
ids.push(res[1]);
break;
case 'update':
if (data[idName] != null)
ids.push(data[idName]);
break;
}
const newWhere = ids.length ? {[idName]: ids} : where;
const stmt = this.buildSelectStmt(op, data, idName, model, newWhere, limit);
newInstances = await this.executeStmt(stmt, opts);
}
}
await this.createLogRecord(oldInstances, newInstances, model, opts);
await this.grabUserLog(model, opts, 'logout');
if (tx) await tx.commit();
return res;
} catch (err) {
if (tx) tx.rollback();
throw err;
}
}
async grabUserLog(model, opts, logAction) {
const Model = this.getModelDefinition(model).model;
const settings = Model.definition.settings;
if (!(settings.log && settings.log.grabUser))
return;
if (logAction === 'login') {
const userId = opts.httpCtx && opts.httpCtx.active.accessToken.userId;
const user = await Model.app.models.Account.findById(userId, {fields: ['name']}, opts);
await this.executeP(`CALL account.myUser_loginWithName(?)`, [user.name], opts);
} else
await this.executeP(`CALL account.myUser_logout()`, null, opts);
}
buildSelectStmt(op, data, idName, model, where, limit) {
const Model = this.getModelDefinition(model).model;
const settings = Model.definition.settings;
const properties = Object.keys(Model.definition.properties);
const log = settings.log;
const fields = data ? Object.keys(data) : [];
if (op == 'delete')
properties.forEach(property => fields.push(property));
else {
fields.push(idName);
if (log.relation) fields.push(Model.relations[log.relation].keyFrom);
if (log.showField) fields.push(log.showField);
else {
const showFieldNames = ['name', 'description', 'code', 'nickname'];
for (const field of showFieldNames) {
if (properties.includes(field)) {
log.showField = field;
fields.push(field);
break;
}
}
}
}
const stmt = new ParameterizedSQL(
'SELECT ' +
this.buildColumnNames(model, {fields}) +
' FROM ' +
this.tableEscaped(model)
);
stmt.merge(this.buildWhere(model, where));
if (limit) stmt.merge(`LIMIT 1`);
return stmt;
}
async createLogRecord(oldInstances, newInstances, model, opts) {
function setActionType() {
if (oldInstances && newInstances)
return 'update';
else if (!oldInstances && newInstances)
return 'insert';
return 'delete';
}
const action = setActionType();
if (!newInstances && action != 'delete') return;
const Model = this.getModelDefinition(model).model;
const models = Model.app.models;
const definition = Model.definition;
const log = definition.settings.log;
const primaryKey = this.idName(model);
const originRelation = log.relation;
const originFkField = originRelation
? Model.relations[originRelation].keyFrom
: primaryKey;
// Prevent adding logs when deleting a principal entity (Client, Zone...)
if (action == 'delete' && !originRelation) return;
function map(instances) {
const map = new Map();
if (!instances) return;
for (const instance of instances)
map.set(instance[primaryKey], instance);
return map;
}
const changedModel = definition.name;
const userFk = opts.httpCtx && opts.httpCtx.active.accessToken.userId;
const oldMap = map(oldInstances);
const newMap = map(newInstances);
const ids = (oldMap || newMap).keys();
const logEntries = [];
function insertValuesLogEntry(logEntry, instance) {
logEntry.originFk = instance[originFkField];
logEntry.changedModelId = instance[primaryKey];
if (log.showField) logEntry.changedModelValue = instance[log.showField];
}
for (const id of ids) {
const oldI = oldMap && oldMap.get(id);
const newI = newMap && newMap.get(id);
const logEntry = {
action,
userFk,
changedModel,
};
if (newI) {
insertValuesLogEntry(logEntry, newI);
// Delete unchanged properties
if (oldI) {
Object.keys(oldI).forEach(prop => {
if (newI[prop] == oldI[prop]) {
delete newI[prop];
delete oldI[prop];
}
});
}
} else
insertValuesLogEntry(logEntry, oldI);
logEntry.oldInstance = oldI;
logEntry.newInstance = newI;
logEntries.push(logEntry);
}
await models[log.model].create(logEntries, opts);
}
}
exports.VnMySQL = VnMySQL;

View File

@ -269,6 +269,18 @@ module.exports = Self => {
// Credit changes
if (changes.credit !== undefined)
await Self.changeCredit(ctx, finalState, changes);
const oldInstance = {};
if (!ctx.isNewInstance) {
const newProps = Object.keys(changes);
Object.keys(orgData.__data).forEach(prop => {
if (newProps.includes(prop))
oldInstance[prop] = orgData[prop];
});
}
ctx.hookState.oldInstance = oldInstance;
ctx.hookState.newInstance = changes;
});
Self.observe('after save', async ctx => {

View File

@ -230,37 +230,41 @@ module.exports = Self => {
}
const changes = loggable.getChanges(originalTicket, updatedTicket);
const oldProperties = await loggable.translateValues(Self, changes.old);
const newProperties = await loggable.translateValues(Self, changes.new);
const hasChanges = Object.keys(changes.old).length > 0 || Object.keys(changes.new).length > 0;
await models.TicketLog.create({
originFk: args.id,
userFk: userId,
action: 'update',
changedModel: 'Ticket',
changedModelId: args.id,
oldInstance: oldProperties,
newInstance: newProperties
}, myOptions);
if (hasChanges) {
const oldProperties = await loggable.translateValues(Self, changes.old);
const newProperties = await loggable.translateValues(Self, changes.new);
const salesPersonId = originalTicket.client().salesPersonFk;
if (salesPersonId) {
const origin = ctx.req.headers.origin;
await models.TicketLog.create({
originFk: args.id,
userFk: userId,
action: 'update',
changedModel: 'Ticket',
changedModelId: args.id,
oldInstance: oldProperties,
newInstance: newProperties
}, myOptions);
let changesMade = '';
for (let change in newProperties) {
let value = newProperties[change];
let oldValue = oldProperties[change];
const salesPersonId = originalTicket.client().salesPersonFk;
if (salesPersonId) {
const origin = ctx.req.headers.origin;
changesMade += `\r\n~${$t(change)}: ${oldValue}~ ➔ *${$t(change)}: ${value}*`;
let changesMade = '';
for (let change in newProperties) {
let value = newProperties[change];
let oldValue = oldProperties[change];
changesMade += `\r\n~${$t(change)}: ${oldValue}~ ➔ *${$t(change)}: ${value}*`;
}
const message = $t('Changed this data from the ticket', {
ticketId: args.id,
ticketUrl: `${origin}/#!/ticket/${args.id}/sale`,
changes: changesMade
});
await models.Chat.sendCheckingPresence(ctx, salesPersonId, message);
}
const message = $t('Changed this data from the ticket', {
ticketId: args.id,
ticketUrl: `${origin}/#!/ticket/${args.id}/sale`,
changes: changesMade
});
await models.Chat.sendCheckingPresence(ctx, salesPersonId, message);
}
res.id = args.id;

View File

@ -2,13 +2,13 @@ const models = require('vn-loopback/server/server').models;
const LoopBackContext = require('loopback-context');
describe('ticket merge()', () => {
const tickets = [{
const tickets = {
originId: 13,
destinationId: 12,
originShipped: new Date(),
destinationShipped: new Date(),
workerFk: 1
}];
};
const activeCtx = {
accessToken: {userId: 9},
@ -37,14 +37,14 @@ describe('ticket merge()', () => {
const options = {transaction: tx};
const chatNotificationBeforeMerge = await models.Chat.find();
await models.Ticket.merge(ctx, tickets, options);
await models.Ticket.merge(ctx, [tickets], options);
const createdTicketLog = await models.TicketLog.find({where: {originFk: tickets[0].originId}}, options);
const deletedTicket = await models.Ticket.findOne({where: {id: tickets[0].originId}}, options);
const salesTicketFuture = await models.Sale.find({where: {ticketFk: tickets[0].destinationId}}, options);
const createdTicketLog = await models.TicketLog.find({where: {originFk: tickets.originId}}, options);
const deletedTicket = await models.Ticket.findOne({where: {id: tickets.originId}}, options);
const salesTicketFuture = await models.Sale.find({where: {ticketFk: tickets.destinationId}}, options);
const chatNotificationAfterMerge = await models.Chat.find();
expect(createdTicketLog.length).toEqual(1);
expect(createdTicketLog.length).toEqual(2);
expect(deletedTicket.isDeleted).toEqual(true);
expect(salesTicketFuture.length).toEqual(2);
expect(chatNotificationBeforeMerge.length).toEqual(chatNotificationAfterMerge.length - 2);