diff --git a/app/imports/api/library/LibraryNodes.js b/app/imports/api/library/LibraryNodes.js index 9c271c4e..b24b5324 100644 --- a/app/imports/api/library/LibraryNodes.js +++ b/app/imports/api/library/LibraryNodes.js @@ -59,7 +59,7 @@ let LibraryNodeSchema = new SimpleSchema({ }, libraryTags: { type: Array, - defaultValue: [], + optional: true, maxCount: STORAGE_LIMITS.tagCount, }, 'libraryTags.$': { @@ -72,12 +72,14 @@ let LibraryNodeSchema = new SimpleSchema({ optional: true, max: STORAGE_LIMITS.variableName, }, + /* TODO: Disabled for now until image upload is working // Image to display when filling the slot slotFillImage: { type: String, optional: true, max: STORAGE_LIMITS.url, }, + */ // Fill more than one quantity in a slot, like feats and ability score // improvements, filtered out of UI if there isn't space in quantityExpected slotQuantityFilled: { diff --git a/app/imports/api/properties/Folders.js b/app/imports/api/properties/Folders.js index 66a10355..0d15f840 100644 --- a/app/imports/api/properties/Folders.js +++ b/app/imports/api/properties/Folders.js @@ -1,14 +1,19 @@ +import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; // Folders organize a character sheet into a tree, particularly to group things // like 'race' and 'background' -let FolderSchema = new createPropertySchema({ +let FolderSchema = createPropertySchema({ name: { type: String, max: STORAGE_LIMITS.name, optional: true, }, + description: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, groupStats: { type: Boolean, optional: true, @@ -33,6 +38,19 @@ let FolderSchema = new createPropertySchema({ }, }); -const ComputedOnlyFolderSchema = new createPropertySchema({}); +const ComputedOnlyFolderSchema = createPropertySchema({ + summary: { + type: 'computedOnlyInlineCalculationField', + optional: true, + }, + description: { + type: 'computedOnlyInlineCalculationField', + optional: true, + }, +}); -export { FolderSchema, ComputedOnlyFolderSchema }; +const ComputedFolderSchema = new SimpleSchema() + .extend(FolderSchema) + .extend(ComputedOnlyFolderSchema); + +export { FolderSchema, ComputedFolderSchema, ComputedOnlyFolderSchema }; diff --git a/app/imports/api/properties/computedPropertySchemasIndex.js b/app/imports/api/properties/computedPropertySchemasIndex.js index 7bdfb302..01fc3f44 100644 --- a/app/imports/api/properties/computedPropertySchemasIndex.js +++ b/app/imports/api/properties/computedPropertySchemasIndex.js @@ -13,7 +13,7 @@ import { ComputedDamageSchema } from '/imports/api/properties/Damages.js'; import { DamageMultiplierSchema } from '/imports/api/properties/DamageMultipliers.js'; import { ComputedEffectSchema } from '/imports/api/properties/Effects.js'; import { ComputedFeatureSchema } from '/imports/api/properties/Features.js'; -import { FolderSchema } from '/imports/api/properties/Folders.js'; +import { ComputedFolderSchema } from '/imports/api/properties/Folders.js'; import { ComputedItemSchema } from '/imports/api/properties/Items.js'; import { ComputedNoteSchema } from '/imports/api/properties/Notes.js'; import { ComputedPointBuySchema } from '/imports/api/properties/PointBuys.js'; @@ -43,7 +43,7 @@ const propertySchemasIndex = { damageMultiplier: DamageMultiplierSchema, effect: ComputedEffectSchema, feature: ComputedFeatureSchema, - folder: FolderSchema, + folder: ComputedFolderSchema, note: ComputedNoteSchema, pointBuy: ComputedPointBuySchema, proficiency: ProficiencySchema, diff --git a/app/imports/constants/SCHEMA_VERSION.js b/app/imports/constants/SCHEMA_VERSION.js index 4ac57384..829e4147 100644 --- a/app/imports/constants/SCHEMA_VERSION.js +++ b/app/imports/constants/SCHEMA_VERSION.js @@ -1,3 +1,3 @@ -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; export default SCHEMA_VERSION; diff --git a/app/imports/migrations/server/dbv1/dbv1.js b/app/imports/migrations/server/dbv1/dbv1.js index 8ecfef81..afcce135 100644 --- a/app/imports/migrations/server/dbv1/dbv1.js +++ b/app/imports/migrations/server/dbv1/dbv1.js @@ -10,52 +10,52 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; Migrations.add({ version: 1, name: 'Unifies calculated field schema', - up(){ + up() { migrate(); }, - down(){ - migrate({reversed: true}); + down() { + migrate({ reversed: true }); }, }); -function migrate({reversed} = {}){ +function migrate({ reversed } = {}) { console.log('migrating creature properties'); - migrateCollection({collection: CreatureProperties, reversed}); + migrateCollection({ collection: CreatureProperties, reversed }); console.log('migrating library nodes') - migrateCollection({collection: LibraryNodes, reversed}); + migrateCollection({ collection: LibraryNodes, reversed }); } -function migrateCollection({collection, reversed}){ +function migrateCollection({ collection, reversed }) { const bulk = collection.rawCollection().initializeUnorderedBulkOp(); collection.find({}).forEach(prop => { - const newProp = migrateProperty({collection, reversed, prop}); + const newProp = migrateProperty({ collection, reversed, prop }); bulk.find({ _id: prop._id }).replaceOne(newProp); }); bulk.execute(); } -export function migrateProperty({collection, reversed, prop}){ +export function migrateProperty({ collection, reversed, prop }) { const transforms = [ ...(transformsByPropType[prop.type] || []), - {from: 'dependencies'} + { from: 'dependencies' } ]; let migratedProp = transformFields(prop, transforms, reversed); - const schema = collection.simpleSchema({type: migratedProp.type}); + const schema = collection.simpleSchema({ type: migratedProp.type }); // Only clean if the schema version matches our destination version - if(!reversed && SCHEMA_VERSION === 1){ + if (!reversed && SCHEMA_VERSION >= 1) { try { migratedProp = schema.clean(migratedProp); schema.validate(migratedProp); - } catch(e){ - if (e.details[0]?.type === 'maxString'){ + } catch (e) { + if (e.details[0]?.type === 'maxString') { console.log({ prop: prop, details: e.details, }); } else { - console.warn({prop, error: e}); + console.warn({ prop, error: e }); } } } @@ -74,31 +74,31 @@ const transformsByPropType = { 'action': actionTransforms, 'adjustment': [ ...getComputedPropertyTransforms('amount'), - {from: 'target', to: 'target', up: simplifyTarget}, + { from: 'target', to: 'target', up: simplifyTarget }, ], 'attack': [ ...actionTransforms, ...getComputedPropertyTransforms('rollBonus', 'attackRoll'), //change type to action - {from: 'type', to: 'type', up: () => 'action'}, + { from: 'type', to: 'type', up: () => 'action' }, ], 'attribute': [ // from: baseValue must be first or else it will delete the field we need - {from: 'baseValue', to: 'baseValue.value', up: nanToNull}, - {from: 'baseValueCalculation', to: 'baseValue.calculation', up: calculationUp, down: calculationDown}, - {from: 'baseValueErrors', to: 'baseValue.errors', up: trimErrors}, + { from: 'baseValue', to: 'baseValue.value', up: nanToNull }, + { from: 'baseValueCalculation', to: 'baseValue.calculation', up: calculationUp, down: calculationDown }, + { from: 'baseValueErrors', to: 'baseValue.errors', up: trimErrors }, ...getComputedPropertyTransforms('spellSlotLevel'), ...getInlineComputationTransforms('description'), - {from: 'value', to: 'total', up: nanToNull}, - {from: 'currentValue', to: 'value', up: nanToNull}, - {from: 'proficiency', to: 'proficiency', up: stripZero}, + { from: 'value', to: 'total', up: nanToNull }, + { from: 'currentValue', to: 'value', up: nanToNull }, + { from: 'proficiency', to: 'proficiency', up: stripZero }, ], 'buff': [ ...getComputedPropertyTransforms('duration'), ...getInlineComputationTransforms('description'), - {from: 'value', to: 'total', up: nanToNull}, - {from: 'target', to: 'target', up: simplifyTarget}, - {from: 'applied'}, + { from: 'value', to: 'total', up: nanToNull }, + { from: 'target', to: 'target', up: simplifyTarget }, + { from: 'applied' }, ], 'classLevel': [ ...getInlineComputationTransforms('description'), @@ -108,20 +108,22 @@ const transformsByPropType = { ], 'damage': [ ...getComputedPropertyTransforms('amount'), - {from: 'target', to: 'target', up: simplifyTarget}, + { from: 'target', to: 'target', up: simplifyTarget }, ], 'effect': [ - {from: 'calculation', to: 'amount.calculation'}, - {from: 'result', to: 'amount.value', up: nanToNull}, - {from: 'errors', to: 'amount.errors', up: trimErrors}, - {from: 'name', to: 'name', up(val, src, doc){ - if (src.operation === 'conditional'){ - doc.text = val; - return; - } else { - return val; + { from: 'calculation', to: 'amount.calculation' }, + { from: 'result', to: 'amount.value', up: nanToNull }, + { from: 'errors', to: 'amount.errors', up: trimErrors }, + { + from: 'name', to: 'name', up(val, src, doc) { + if (src.operation === 'conditional') { + doc.text = val; + return; + } else { + return val; + } } - }}, + }, ], 'feature': [ ...getInlineComputationTransforms('summary'), @@ -139,20 +141,20 @@ const transformsByPropType = { ], 'savingThrow': [ ...getComputedPropertyTransforms('dc'), - {from: 'target', to: 'target', up: simplifyTarget}, + { from: 'target', to: 'target', up: simplifyTarget }, ], 'skill': [ ...getComputedPropertyTransforms('baseValue'), ...getInlineComputationTransforms('description'), - {from: 'value', to: 'value', up: nanToNull}, - {from: 'passiveBonus', to: 'passiveBonus', up: nanToNull}, - {from: 'proficiency', to: 'proficiency', up: stripZero}, + { from: 'value', to: 'value', up: nanToNull }, + { from: 'passiveBonus', to: 'passiveBonus', up: nanToNull }, + { from: 'proficiency', to: 'proficiency', up: stripZero }, ], 'spell': [ ...actionTransforms, ], 'proficiency': [ - {from: 'value', to: 'value', up: stripZero}, + { from: 'value', to: 'value', up: stripZero }, ], 'propertySlot': [ ...getComputedPropertyTransforms('quantityExpected'), @@ -166,70 +168,70 @@ const transformsByPropType = { ...getInlineComputationTransforms('description'), ], 'toggle': [ - {from: 'condition', to: 'condition.calculation'}, - {from: 'toggleResult', to: 'condition.value', up: nanToNull}, - {from: 'errors', to: 'condition.errors', up: trimErrors}, + { from: 'condition', to: 'condition.calculation' }, + { from: 'toggleResult', to: 'condition.value', up: nanToNull }, + { from: 'errors', to: 'condition.errors', up: trimErrors }, ], }; -function getComputedPropertyTransforms(key, toKey){ +function getComputedPropertyTransforms(key, toKey) { if (!toKey) toKey = key; return [ - {from: key, to: `${toKey}.calculation`, up: calculationUp, down: calculationDown}, - {from: `${key}Result`, to: `${toKey}.value`, up: nanToNull}, - {from: `${key}Errors`, to: `${toKey}.errors`, up: trimErrors}, + { from: key, to: `${toKey}.calculation`, up: calculationUp, down: calculationDown }, + { from: `${key}Result`, to: `${toKey}.value`, up: nanToNull }, + { from: `${key}Errors`, to: `${toKey}.errors`, up: trimErrors }, ]; } -function getInlineComputationTransforms(key){ +function getInlineComputationTransforms(key) { return [ - {from: key, to: `${key}.text`, up: calculationUp, down: calculationDown}, - {from: `${key}Calculations`, to: `${key}.inlineCalculations`, up: calculationUp, down: calculationDown}, - {from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value`}, + { from: key, to: `${key}.text`, up: calculationUp, down: calculationDown }, + { from: `${key}Calculations`, to: `${key}.inlineCalculations`, up: calculationUp, down: calculationDown }, + { from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value` }, ]; } -export function calculationUp(val){ +export function calculationUp(val) { if (typeof val !== 'string') return val; - if (!val.replace) console.log({val, replace: val.replace}); + if (!val.replace) console.log({ val, replace: val.replace }); return val.replace(/#(\w+).(\w+)Result/g, '#$1.$2') .replace(/\.value/g, '.total') .replace(/\.currentValue/g, '.value'); } -function calculationDown(val){ +function calculationDown(val) { if (typeof val !== 'string') return val; return val.replace(/\.value/g, '.currentValue').replace(/\.total/g, '.value'); } -function nanToNull(val){ - if (Number.isNaN(val)){ +function nanToNull(val) { + if (Number.isNaN(val)) { return null; } else { return val; } } -function stripZero(val){ - if (val === 0){ +function stripZero(val) { + if (val === 0) { return undefined; } else { return val; } } -function simplifyTarget(val){ - if (val === 'self'){ +function simplifyTarget(val) { + if (val === 'self') { return val; } else { return 'target'; } } -function trimErrors(arr){ - if(!arr) return arr; +function trimErrors(arr) { + if (!arr) return arr; arr.forEach(e => { - if (e.message.length > STORAGE_LIMITS.errorMessage){ + if (e.message.length > STORAGE_LIMITS.errorMessage) { e.message = e.message.slice(0, STORAGE_LIMITS.errorMessage); } }); diff --git a/app/imports/migrations/server/dbv1/dbv1.test.js b/app/imports/migrations/server/dbv1/dbv1.test.js index efbd4fdd..edc81d81 100644 --- a/app/imports/migrations/server/dbv1/dbv1.test.js +++ b/app/imports/migrations/server/dbv1/dbv1.test.js @@ -31,7 +31,7 @@ const exampleAction = { 'ancestors': [{ 'collection': 'creatures', 'id': 'X9rzFhsgFhodYfHmG' - }, ], + },], 'order': 315, 'summary': 'Curse a creature for 1 minute. The curse ends early if {warlock.level >14 ? "" : "the target dies, or"} you are incapacitated. \nGain the following benefits: \n- *Bonus to damage rolls against the cursed target of* **+{proficiencyBonus}**. \n- Any attack roll you make against the cursed target is a **critical hit on a roll of 19 or 20**. \n- If the cursed target dies, you **regain {warlock.level+charisma.modifier} hit points**. \n{warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."}', 'uses': '1', @@ -45,21 +45,21 @@ const exampleAction = { 'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.' }], 'summaryCalculations': [{ - 'calculation': 'warlock.level >14 ? "" : "the target dies, or"', - 'result': 'the target dies, or' - }, - { - 'calculation': 'proficiencyBonus', - 'result': '4' - }, - { - 'calculation': 'warlock.level+charisma.modifier', - 'result': '15' - }, - { - 'calculation': 'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."', - 'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.' - } + 'calculation': 'warlock.level >14 ? "" : "the target dies, or"', + 'result': 'the target dies, or' + }, + { + 'calculation': 'proficiencyBonus', + 'result': '4' + }, + { + 'calculation': 'warlock.level+charisma.modifier', + 'result': '15' + }, + { + 'calculation': 'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."', + 'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.' + } ] }; @@ -76,13 +76,13 @@ const exampleAttribute = { 'collection': 'creatureProperties' }, ancestors: [{ - 'collection': 'creatures', - 'id': 'm9sdCvs6iDf7qRaGv' - }, - { - 'id': '8jSWKxvgQyKbunFtD', - 'collection': 'creatureProperties' - } + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv' + }, + { + 'id': '8jSWKxvgQyKbunFtD', + 'collection': 'creatureProperties' + } ], order: 84, value: 20, @@ -110,13 +110,13 @@ const expectedMigratedAttribute = { 'collection': 'creatureProperties' }, ancestors: [{ - 'collection': 'creatures', - 'id': 'm9sdCvs6iDf7qRaGv' - }, - { - 'id': '8jSWKxvgQyKbunFtD', - 'collection': 'creatureProperties' - } + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv' + }, + { + 'id': '8jSWKxvgQyKbunFtD', + 'collection': 'creatureProperties' + } ], order: 84, total: 20, @@ -205,11 +205,10 @@ const expectedMigratedAttack = { }], 'order': 56, 'usesUsed': 2, - libraryTags: [], } -describe('migrateProperty', function() { - it('Migrates actions reversibly', function() { +describe('migrateProperty', function () { + it('Migrates actions reversibly', function () { const action = { ...exampleAction }; @@ -226,7 +225,7 @@ describe('migrateProperty', function() { assert.deepEqual(action, exampleAction, 'action should not be bashed'); assert.deepEqual(exampleAction, reversedAction, 'operation should be reversible'); }); - it('Migrates attributes as expected', function() { + it('Migrates attributes as expected', function () { const attribute = { ...exampleAttribute }; @@ -237,7 +236,7 @@ describe('migrateProperty', function() { assert.deepEqual(newAttribute, expectedMigratedAttribute, 'Attribute should match the expected result'); }); - it('Migrates attacks as expected', function() { + it('Migrates attacks as expected', function () { const attribute = { ...exampleAttack }; diff --git a/app/imports/migrations/server/dbv2/cleanAt2.js b/app/imports/migrations/server/dbv2/cleanAt2.js new file mode 100644 index 00000000..f2dc9957 --- /dev/null +++ b/app/imports/migrations/server/dbv2/cleanAt2.js @@ -0,0 +1,3 @@ +export default function cleanAt2() { + return; +} diff --git a/app/imports/migrations/server/dbv2/dbv2.js b/app/imports/migrations/server/dbv2/dbv2.js new file mode 100644 index 00000000..7770ccff --- /dev/null +++ b/app/imports/migrations/server/dbv2/dbv2.js @@ -0,0 +1,56 @@ +import { Migrations } from 'meteor/percolate:migrations'; +import LibraryNodes from '/imports/api/library/LibraryNodes.js'; +import { union } from 'lodash'; + +// Git version 2.0-beta.33 +// Database version 1 +Migrations.add({ + version: 2, + name: 'Separates creature property tags from library tags', + + up() { + console.log('migrating up library nodes 1 -> 2'); + const bulk = LibraryNodes.rawCollection().initializeUnorderedBulkOp(); + LibraryNodes.find({}).forEach(prop => migratePropUp(bulk, prop)); + bulk.execute(); + }, + + down() { + console.log('migrating down library nodes 2 -> 1'); + const bulk = LibraryNodes.rawCollection().initializeUnorderedBulkOp(); + LibraryNodes.find({}).forEach(prop => migratePropDown(bulk, prop)); + bulk.execute(); + }, + +}); + +export function migratePropUp(bulk, prop) { + // If there are tags, copy them to libraryTags and set findable flags + if (Array.isArray(prop.tags) && prop.tags.length) { + bulk.find({ _id: prop._id }).updateOne({ + $set: { + libraryTags: prop.tags, + fillSlots: true, + searchable: true, + }, + }); + } +} + +export function migratePropDown(bulk, prop) { + const update = { + $unset: { + slotFillImage: 1, + slotFillerCondition: 1, + libraryTags: 1, + fillSlots: 1, + searchable: 1, + } + }; + if (prop.libraryTags?.length) { + update.$set = { + tags: union(prop.libraryTags, prop.tags) + } + } + bulk.find({ _id: prop._id }).updateOne(update); +} diff --git a/app/imports/migrations/server/dbv2/dbv2.test.js b/app/imports/migrations/server/dbv2/dbv2.test.js new file mode 100644 index 00000000..67eab7ba --- /dev/null +++ b/app/imports/migrations/server/dbv2/dbv2.test.js @@ -0,0 +1,134 @@ +import { migratePropUp, migratePropDown } from './dbv2.js'; +import { assert } from 'chai'; + +const exampleAttack = { + '_id': 'vw23EnJwBRcXEJg7i', + 'actionType': 'attack', + 'target': 'singleTarget', + 'tags': ['attack', 'magical', 'very cool'], + 'resources': { + 'itemsConsumed': [], + 'attributesConsumed': [] + }, + 'attackRoll': { + calculation: 'dexterity.modifier + proficiencyBonus + 2 - hp.total + hp.value', + }, + 'type': 'action', + 'name': 'Claws', + 'parent': { + 'id': 'Jpx8q3WjM5SCoGBm8', + 'collection': 'creatureProperties' + }, + 'ancestors': [{ + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv' + }, { + 'id': '3WS2xsSPAqB4eF9YH', + 'collection': 'creatureProperties' + }, { + 'id': 'rhYLEycvtHjcioaQL', + 'collection': 'creatureProperties' + }, { + 'id': 'Jpx8q3WjM5SCoGBm8', + 'collection': 'creatureProperties' + }], + 'order': 56, + 'usesUsed': 2, +}; + +const expectedAttackUpdate = { + $set: { + 'libraryTags': ['attack', 'magical', 'very cool'], + 'fillSlots': true, + 'searchable': true, + } +}; + +const emptyFolderExample = { + _id: 'DXPYsHKF6W8Hh3hZs', + type: 'folder', + name: 'Empty Folder', + 'parent': { + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }, + 'ancestors': [{ + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }], +}; + +const DownMergeExample = { + _id: 'DXPYsHKF6W8Hh3hZs', + type: 'feature', + name: 'Feature With Tags and library Tags', + 'parent': { + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }, + 'ancestors': [{ + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }], + 'libraryTags': ['tags', 'from', 'library'], + 'tags': ['attack', 'magical', 'very cool'], +}; + +const expectedDownMergeUpdate = { + $unset: { + slotFillImage: 1, + slotFillerCondition: 1, + libraryTags: 1, + fillSlots: 1, + searchable: 1, + }, + $set: { + tags: ['tags', 'from', 'library', 'attack', 'magical', 'very cool'], + } +}; + +describe('dbv2 Migrate library nodes', function () { + it('Migrates attacks up', function () { + const bulk = stubBulk(); + migratePropUp(bulk, exampleAttack); + const { query, update } = bulk.result(); + assert.deepEqual(query, { _id: 'vw23EnJwBRcXEJg7i' }, 'The query should match the id of the given prop'); + assert.deepEqual(update, expectedAttackUpdate, 'The update should match the expected update'); + }); + it('Migrates props without tags up', function () { + const bulk = stubBulk(); + migratePropUp(bulk, emptyFolderExample); + const { query, update, timesFind, timesUpdate } = bulk.result(); + assert.isUndefined(query, 'There should be no query on a prop with no tags'); + assert.equal(timesFind, 0, 'Find should be called zero times on a prop with no tags'); + assert.isUndefined(update, 'There should be no update on a prop with no tags'); + assert.equal(timesUpdate, 0, 'Update should be called zero times on a prop with no tags'); + }); + it('Merges tags when down migrating', function () { + const bulk = stubBulk(); + migratePropDown(bulk, DownMergeExample); + const { query, update } = bulk.result(); + assert.deepEqual(query, { _id: 'DXPYsHKF6W8Hh3hZs' }, 'The query should match the id of the given prop'); + assert.deepEqual(update, expectedDownMergeUpdate, 'The update should match the expected update'); + }); +}); + +// Create a stub for bulk udateOne operations that accepts a single op +function stubBulk() { + let query, update, timesFind = 0, timesUpdate = 0; + return { + find(inputQuery) { + query = inputQuery; + timesFind += 1; + return { + updateOne(inputUpdate) { + update = inputUpdate; + timesUpdate += 1; + } + } + }, + result() { + return { query, update, timesFind, timesUpdate } + } + } +} \ No newline at end of file