diff --git a/src/parse-mockdb.js b/src/parse-mockdb.js index 1b87fab..880786f 100644 --- a/src/parse-mockdb.js +++ b/src/parse-mockdb.js @@ -678,8 +678,11 @@ function handleGetRequest(request) { * @param {string} className The name of the class to get the hook on. * @param {string} hookType One of 'beforeSave', 'afterSave', 'beforeDelete', 'afterDelete' * @param {Object} data The Data that is to be hydrated into an instance of className class. + * @param {Object} originalData The original data (as it was before an update) or undefined + * if it was just created; will also be hydrated into an instance + * of className class. */ -function runHook(className, hookType, data) { +function runHook(className, hookType, data, originalData) { let hook = getHook(className, hookType); if (hook) { const hydrate = (rawData) => { @@ -690,14 +693,11 @@ function runHook(className, hookType, data) { ); return Parse.Object.fromJSON(modelJSON); }; - const model = hydrate(data, className); + const model = hydrate(data); hook = hook.bind(model); - const collection = getCollection(className); - let original; - if (collection[model.id]) { - original = hydrate(collection[model.id]); - } + const original = (originalData !== undefined) ? hydrate(originalData) : undefined; + // TODO Stub out Parse.Cloud.useMasterKey() so that we can report the correct 'master' // value here. return hook(makeRequestObject(original, model, false)).then((beforeSaveOverrideValue) => { @@ -792,7 +792,7 @@ function handlePutRequest(request) { applyOps(updatedObject, ops, className); const toOmit = ['createdAt', 'objectId'].concat(Array.from(getMask(className))); - return runHook(className, 'beforeSave', updatedObject).then(result => { + return runHook(className, 'beforeSave', updatedObject, currentObject).then(result => { const changedKeys = getChangedKeys(updatedObject, result); collection[request.objectId] = updatedObject; @@ -802,7 +802,7 @@ function handlePutRequest(request) { ); return Promise.resolve(respond(200, response)); }).then((result) => { - runHook(className, 'afterSave', updatedObject); + runHook(className, 'afterSave', updatedObject, currentObject); return result; }); } diff --git a/test/original_in_aftersave/SCENARIO.md b/test/original_in_aftersave/SCENARIO.md new file mode 100644 index 0000000..aacd01b --- /dev/null +++ b/test/original_in_aftersave/SCENARIO.md @@ -0,0 +1,111 @@ +### Problem + +The `afterSave` hook in **Parse MockDB** behaves differently to a +real Parse Server (v4.4.0 at time of writing). + +In a real server, the `afterSave` hook will provide the original object +from before the save as `request.original`, as well as the saved object +in `request.object` as expected. In other words, it should provide the +same objects on the `request` object as you would see in `beforeSave`. + +Thus, any cloud code which relies on using this information to perform +certain actions when particular data has changed isn't able to be unit +tested via Parse MockDB. + +### Testing + +- Cloud Code is here: [`main.js`](./main.js) +- Client-side simulation script is here: [`test_context.js`](./test_context.js) + +The cloud code just logs out the `request.original` and `request.object` +in the `beforeSave` hook and the `afterSave` hook. + +The test script just creates an object and then updates it. + +Run the test Parse Server via Docker as below: + +```bash +$ docker run --rm --name test-mongo -d mongo && \ +> docker run --rm --name test-parse-server -p 1337:1337 -e VERBOSE=0 \ +> -v $(pwd):/parse-server/cloud --link test-mongo parseplatform/parse-server \ +> --appId test-app --masterKey "M@stErK3y" \ +> --databaseURI "mongodb://test-mongo/test" --cloud /parse-server/cloud/main.js +... + +[1] parse-server running on http://localhost:1337/parse +... +``` + +After running the test script in a separate terminal: + +```bash +$ node test_context.js +Created { foo: 'bar', + createdAt: '2020-11-09T07:42:11.956Z', + updatedAt: '2020-11-09T07:42:11.956Z', + objectId: 'zwZdxCML9F' } +Updated { foo: 'baaaah', + createdAt: '2020-11-09T07:42:11.956Z', + updatedAt: '2020-11-09T07:42:12.043Z', + objectId: 'zwZdxCML9F' } +``` + +You will see this in the server logs (split into two +chunks: (1) create then (2) update): + +**Create** +``` +BEFORE: undefined -> { foo: 'bar' } +info: beforeSave triggered for Thing for user undefined: + Input: {"foo":"bar"} + Result: {"object":{"foo":"bar"}} {"className":"Thing","triggerType":"beforeSave"} +AFTER: undefined -> { + foo: 'bar', + createdAt: '2020-11-09T07:42:11.956Z', + updatedAt: '2020-11-09T07:42:11.956Z', + objectId: 'zwZdxCML9F' +} +info: afterSave triggered for Thing for user undefined: + Input: {"foo":"bar","createdAt":"2020-11-09T07:42:11.956Z","updatedAt":"2020-11-09T07:42:11.956Z","objectId":"zwZdxCML9F"} {"className":"Thing","triggerType":"afterSave"} +info: afterSave triggered for Thing for user undefined: + Input: {"foo":"bar","createdAt":"2020-11-09T07:42:11.956Z","updatedAt":"2020-11-09T07:42:11.956Z","objectId":"zwZdxCML9F"} + Result: undefined {"className":"Thing","triggerType":"afterSave"} +``` + +**Update** +``` +BEFORE: { + foo: 'bar', + createdAt: '2020-11-09T07:42:11.956Z', + updatedAt: '2020-11-09T07:42:11.956Z', + objectId: 'zwZdxCML9F' +} -> { + foo: 'baaaah', + createdAt: '2020-11-09T07:42:11.956Z', + updatedAt: '2020-11-09T07:42:11.956Z', + objectId: 'zwZdxCML9F' +} +info: beforeSave triggered for Thing for user undefined: + Input: {"foo":"baaaah","createdAt":"2020-11-09T07:42:11.956Z","updatedAt":"2020-11-09T07:42:11.956Z","objectId":"zwZdxCML9F"} + Result: {"object":{"foo":"baaaah"}} {"className":"Thing","triggerType":"beforeSave"} +AFTER: { + foo: 'bar', + createdAt: '2020-11-09T07:42:11.956Z', + updatedAt: '2020-11-09T07:42:11.956Z', + objectId: 'zwZdxCML9F' +} -> { + foo: 'baaaah', + createdAt: '2020-11-09T07:42:11.956Z', + updatedAt: '2020-11-09T07:42:12.043Z', + objectId: 'zwZdxCML9F' +} +info: afterSave triggered for Thing for user undefined: + Input: {"foo":"baaaah","createdAt":"2020-11-09T07:42:11.956Z","updatedAt":"2020-11-09T07:42:12.043Z","objectId":"zwZdxCML9F"} {"className":"Thing","triggerType":"afterSave"} +info: afterSave triggered for Thing for user undefined: + Input: {"foo":"baaaah","createdAt":"2020-11-09T07:42:11.956Z","updatedAt":"2020-11-09T07:42:12.043Z","objectId":"zwZdxCML9F"} + Result: undefined {"className":"Thing","triggerType":"afterSave"} +``` + +As you can see in the final part of the logs above, the +original object and the saved object are sent through to +`afterSave`. diff --git a/test/original_in_aftersave/main.js b/test/original_in_aftersave/main.js new file mode 100644 index 0000000..3c6efb7 --- /dev/null +++ b/test/original_in_aftersave/main.js @@ -0,0 +1,11 @@ +/* global Parse */ + +Parse.Cloud.beforeSave('Thing', (req /* , res */) => { + console.log('BEFORE:', + req.original !== undefined ? req.original.toJSON() : undefined, '->', req.object.toJSON()); +}); + +Parse.Cloud.afterSave('Thing', (req /* , res */) => { + console.log('AFTER:', + req.original !== undefined ? req.original.toJSON() : undefined, '->', req.object.toJSON()); +}); diff --git a/test/original_in_aftersave/test_context.js b/test/original_in_aftersave/test_context.js new file mode 100644 index 0000000..1479af5 --- /dev/null +++ b/test/original_in_aftersave/test_context.js @@ -0,0 +1,14 @@ +const Parse = require('parse/node'); + +Parse.initialize('test-app', null, 'M@stErK3y'); +Parse.serverURL = 'http://localhost:1337/parse'; + +const Thing = Parse.Object.extend('Thing'); +const thing = new Thing(); + +thing.save({ foo: 'bar' }) + .then((t1) => { + console.log('Created %o', t1.toJSON()); + return t1.save({ foo: 'baaaah' }); + }) + .then((t2) => console.log('Updated %o', t2.toJSON())); diff --git a/test/test.js b/test/test.js index 58ad718..61f2e77 100644 --- a/test/test.js +++ b/test/test.js @@ -185,15 +185,19 @@ function behavesLikeParseObjectOnAfterSave(typeName, ParseObjectOrUserSubclass) context('when object has afterSave hook registered', () => { let didAfterSave; let objectInAfterSave; + let originalObjectInAfterSave; function afterSavePromise(request) { + const { original, object } = request; didAfterSave = true; - objectInAfterSave = request.object; + originalObjectInAfterSave = original; + objectInAfterSave = object; return Promise.resolve(); } beforeEach(() => { didAfterSave = false; objectInAfterSave = {}; + originalObjectInAfterSave = {}; }); context('when saving a new object', () => { @@ -289,6 +293,22 @@ function behavesLikeParseObjectOnAfterSave(typeName, ParseObjectOrUserSubclass) }); }); + it('provide the original and saved object to the afterSave hook', () => { + ParseMockDB.registerHook(typeName, 'afterSave', afterSavePromise); + object.set('name', 'updated'); + return object.save().then(() => { + assert.equal( + originalObjectInAfterSave.get('name'), + 'original' + ); + + assert.equal( + objectInAfterSave.get('name'), + 'updated' + ); + }); + }); + context('when the afterSave hook hits an error', () => { beforeEach(() => { const badHook = () => Promise.reject(new Error('Something went wrong'));