From a451e562fbf4ad234f02158daec8ad69da0ae93e Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Tue, 6 Dec 2022 00:01:38 -0600 Subject: [PATCH 01/10] add initial set of tests for api --- server/homebrew.api.js | 606 ++++++++++++++++++------------------ server/homebrew.api.spec.js | 377 ++++++++++++++++++++++ 2 files changed, 674 insertions(+), 309 deletions(-) create mode 100644 server/homebrew.api.spec.js diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 563926f01..2203367ef 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -15,328 +15,316 @@ const { nanoid } = require('nanoid'); // }); // }; -const getId = (req)=>{ - // Set the id and initial potential google id, where the google id is present on the existing brew. - let id = req.params.id, googleId = req.body?.googleId; - - // If the id is longer than 12, then it's a google id + the edit id. This splits the longer id up. - if(id.length > 12) { - googleId = id.slice(0, -12); - id = id.slice(-12); - } - return { id, googleId }; -}; - -const getBrew = (accessType, fetchGoogle = true)=>{ - // Create middleware with the accessType passed in as part of the scope - return async (req, res, next)=>{ - // Get relevant IDs for the brew - const { id, googleId } = getId(req); - - // Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine. - let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id }) - .catch((err)=>{ - if(googleId) { - console.warn(`Unable to find document stub for ${accessType}Id ${id}`); - } else { - console.warn(err); - } - }); - stub = stub?.toObject(); - if(stub?.authors && !stub?.authors.includes(req.account.username)) { - throw 'Current logged in user does not have access to this brew.'; - } - - // If there is a google id, try to find the google brew - if(fetchGoogle && (googleId || stub?.googleId)) { - let googleError; - const googleBrew = await GoogleActions.getGoogleBrew(googleId || stub?.googleId, id, accessType) - .catch((err)=>{ - console.warn(err); - googleError = err; - }); - // If we can't find the google brew and there is a google id for the brew, throw an error. - if(!googleBrew) throw googleError; - // Combine the Homebrewery stub with the google brew, or if the stub doesn't exist just use the google brew - stub = stub ? _.assign({ ...excludeStubProps(stub), stubbed: true }, excludeGoogleProps(googleBrew)) : googleBrew; - } - - // If after all of that we still don't have a brew, throw an exception - if(!stub && fetchGoogle) { - throw 'Brew not found in Homebrewery database or Google Drive'; - } - - if(typeof stub?.tags === 'string') { - stub.tags = []; - } - req.brew = stub || {}; - - next(); - }; -}; - -const mergeBrewText = (brew)=>{ - let text = brew.text; - if(brew.style !== undefined) { - text = `\`\`\`css\n` + - `${brew.style || ''}\n` + - `\`\`\`\n\n` + - `${text}`; - } - const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']); - text = `\`\`\`metadata\n` + - `${yaml.dump(metadata)}\n` + - `\`\`\`\n\n` + - `${text}`; - return text; -}; - const MAX_TITLE_LENGTH = 100; -const getGoodBrewTitle = (text)=>{ - const tokens = Markdown.marked.lexer(text); - return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title') - .slice(0, MAX_TITLE_LENGTH); -}; +const api = { + homebrewApi : router, + getId : (req)=>{ + // Set the id and initial potential google id, where the google id is present on the existing brew. + let id = req.params.id, googleId = req.body?.googleId; -const excludePropsFromUpdate = (brew)=>{ - // Remove undesired properties - const modified = _.clone(brew); - const propsToExclude = ['_id', 'views', 'lastViewed', 'editId', 'shareId', 'googleId']; - for (const prop of propsToExclude) { - delete modified[prop]; - } - return modified; -}; - -const excludeGoogleProps = (brew)=>{ - const modified = _.clone(brew); - const propsToExclude = ['tags', 'systems', 'published', 'authors', 'owner', 'views', 'thumbnail']; - for (const prop of propsToExclude) { - delete modified[prop]; - } - return modified; -}; - -const excludeStubProps = (brew)=>{ - const propsToExclude = ['text', 'textBin', 'renderer', 'pageCount', 'version']; - for (const prop of propsToExclude) { - brew[prop] = undefined; - } - return brew; -}; - -const beforeNewSave = (account, brew)=>{ - if(!brew.title) { - brew.title = getGoodBrewTitle(brew.text); - } - - brew.authors = (account) ? [account.username] : []; - brew.text = mergeBrewText(brew); -}; - -const newGoogleBrew = async (account, brew, res)=>{ - const oAuth2Client = GoogleActions.authCheck(account, res); - - const newBrew = excludeGoogleProps(brew); - - return await GoogleActions.newGoogleBrew(oAuth2Client, newBrew); -}; - -const newBrew = async (req, res)=>{ - const brew = req.body; - const { saveToGoogle } = req.query; - - delete brew.editId; - delete brew.shareId; - delete brew.googleId; - - beforeNewSave(req.account, brew); - - const newHomebrew = new HomebrewModel(brew); - newHomebrew.editId = nanoid(12); - newHomebrew.shareId = nanoid(12); - - let googleId, saved; - if(saveToGoogle) { - googleId = await newGoogleBrew(req.account, newHomebrew, res) - .catch((err)=>{ - console.error(err); - res.status(err?.status || err?.response?.status || 500).send(err?.message || err); - }); - if(!googleId) return; - excludeStubProps(newHomebrew); - newHomebrew.googleId = googleId; - } else { - // Compress brew text to binary before saving - newHomebrew.textBin = zlib.deflateRawSync(newHomebrew.text); - // Delete the non-binary text field since it's not needed anymore - newHomebrew.text = undefined; - } - - saved = await newHomebrew.save() - .catch((err)=>{ - console.error(err, err.toString(), err.stack); - throw `Error while creating new brew, ${err.toString()}`; - }); - if(!saved) return; - saved = saved.toObject(); - - res.status(200).send(saved); -}; - -const updateBrew = async (req, res)=>{ - // Initialize brew from request and body, destructure query params, set a constant for the google id, and set the initial value for the after-save method - let brew = _.assign(req.brew, excludePropsFromUpdate(req.body)); - const { saveToGoogle, removeFromGoogle } = req.query; - const googleId = brew.googleId; - let afterSave = async ()=>true; - - brew.text = mergeBrewText(brew); - - if(brew.googleId && removeFromGoogle) { - // If the google id exists and we're removing it from google, set afterSave to delete the google brew and mark the brew's google id as undefined - afterSave = async ()=>{ - return await deleteGoogleBrew(req.account, googleId, brew.editId, res) - .catch((err)=>{ - console.error(err); - res.status(err?.status || err?.response?.status || 500).send(err.message || err); - }); - }; - - brew.googleId = undefined; - } else if(!brew.googleId && saveToGoogle) { - // If we don't have a google id and the user wants to save to google, create the google brew and set the google id on the brew - brew.googleId = await newGoogleBrew(req.account, excludeGoogleProps(brew), res) - .catch((err)=>{ - console.error(err); - res.status(err.status || err.response.status).send(err.message || err); - }); - if(!brew.googleId) return; - } else if(brew.googleId) { - // If the google id exists and no other actions are being performed, update the google brew - const updated = await GoogleActions.updateGoogleBrew(excludeGoogleProps(brew)) - .catch((err)=>{ - console.error(err); - res.status(err?.response?.status || 500).send(err); - }); - if(!updated) return; - } - - if(brew.googleId) { - // If the google id exists after all those actions, exclude the props that are stored in google and aren't needed for rendering the brew items - excludeStubProps(brew); - } else { - // Compress brew text to binary before saving - brew.textBin = zlib.deflateRawSync(brew.text); - // Delete the non-binary text field since it's not needed anymore - brew.text = undefined; - } - brew.updatedAt = new Date(); - - if(req.account) { - brew.authors = _.uniq(_.concat(brew.authors, req.account.username)); - } - // we need the tag type change in both getBrew and here to handle the case where we don't have a stub on which to perform the modification - if(typeof brew.tags === 'string') { - brew.tags = []; - } - - // define a function to catch our save errors - const saveError = (err)=>{ - console.error(err); - res.status(err.status || 500).send(err.message || 'Unable to save brew to Homebrewery database'); - }; - let saved; - if(!brew._id) { - // if the brew does not have a stub id, create and save it, then write the new value back to the brew. - saved = await new HomebrewModel(brew).save().catch(saveError); - brew = saved?.toObject(); - } else { - // if the brew does have a stub id, update it using the stub id as the key. - saved = await HomebrewModel.updateOne({ _id: brew._id }, brew).catch(saveError); - } - if(!saved) return; - // Call and wait for afterSave to complete - const after = await afterSave(); - if(!after) return; - - res.status(200).send(brew); -}; - -const deleteGoogleBrew = async (account, id, editId, res)=>{ - const auth = await GoogleActions.authCheck(account, res); - await GoogleActions.deleteGoogleBrew(auth, id, editId); - return true; -}; - -const deleteBrew = async (req, res, next)=>{ - // Delete an orphaned stub if its Google brew doesn't exist - try { - await getBrew('edit')(req, res, ()=>{}); - } catch (err) { - const { id, googleId } = getId(req); - console.warn(`No google brew found for id ${googleId}, the stub with id ${id} will be deleted.`); - await HomebrewModel.deleteOne({ editId: id }); - return next(); - } - - let brew = req.brew; - const { googleId, editId } = brew; - const account = req.account; - const isOwner = account && (brew.authors.length === 0 || brew.authors[0] === account.username); - // If the user is the owner and the file is saved to google, mark the google brew for deletion - const shouldDeleteGoogleBrew = googleId && isOwner; - - if(brew._id) { - brew = _.assign(await HomebrewModel.findOne({ _id: brew._id }), brew); - if(account) { - // Remove current user as author - brew.authors = _.pull(brew.authors, account.username); - brew.markModified('authors'); + // If the id is longer than 12, then it's a google id + the edit id. This splits the longer id up. + if(id.length > 12) { + googleId = id.slice(0, -12); + id = id.slice(-12); } + return { id, googleId }; + }, + getBrew : (accessType, fetchGoogle = true)=>{ + // Create middleware with the accessType passed in as part of the scope + return async (req, res, next)=>{ + // Get relevant IDs for the brew + const { id, googleId } = api.getId(req); - if(brew.authors.length === 0) { - // Delete brew if there are no authors left - await brew.remove() + // Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine. + let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id }) .catch((err)=>{ - console.error(err); - throw { status: 500, message: 'Error while removing' }; + if(googleId) { + console.warn(`Unable to find document stub for ${accessType}Id ${id}`); + } else { + console.warn(err); + } }); - } else { - if(shouldDeleteGoogleBrew) { - // When there are still authors remaining, we delete the google brew but store the full brew in the Homebrewery database - brew.googleId = undefined; - brew.textBin = zlib.deflateRawSync(brew.text); - brew.text = undefined; + stub = stub?.toObject(); + if(accessType === 'edit' && stub?.authors?.length > 0 && !stub?.authors.includes(req.account?.username)) { + throw 'Current logged in user does not have access to this brew.'; } - // Otherwise, save the brew with updated author list - await brew.save() - .catch((err)=>{ - throw { status: 500, message: err }; - }); + // If there is a google id, try to find the google brew + if(fetchGoogle && (googleId || stub?.googleId)) { + let googleError; + const googleBrew = await GoogleActions.getGoogleBrew(googleId || stub?.googleId, id, accessType) + .catch((err)=>{ + console.warn(err); + googleError = err; + }); + // If we can't find the google brew and there is a google id for the brew, throw an error. + if(!googleBrew) throw googleError; + // Combine the Homebrewery stub with the google brew, or if the stub doesn't exist just use the google brew + stub = stub ? _.assign({ ...api.excludeStubProps(stub), stubbed: true }, api.excludeGoogleProps(googleBrew)) : googleBrew; + } + + // If after all of that we still don't have a brew, throw an exception + if(!stub && fetchGoogle) { + throw 'Brew not found in Homebrewery database or Google Drive'; + } + + if(typeof stub?.tags === 'string') { + stub.tags = []; + } + req.brew = stub || {}; + + next(); + }; + }, + mergeBrewText : (brew)=>{ + let text = brew.text; + if(brew.style !== undefined) { + text = `\`\`\`css\n` + + `${brew.style || ''}\n` + + `\`\`\`\n\n` + + `${text}`; } - } - if(shouldDeleteGoogleBrew) { - const deleted = await deleteGoogleBrew(account, googleId, editId, res) + const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']); + text = `\`\`\`metadata\n` + + `${yaml.dump(metadata)}\n` + + `\`\`\`\n\n` + + `${text}`; + return text; + }, + getGoodBrewTitle : (text)=>{ + const tokens = Markdown.marked.lexer(text); + return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title') + .slice(0, MAX_TITLE_LENGTH); + }, + excludePropsFromUpdate : (brew)=>{ + // Remove undesired properties + const modified = _.clone(brew); + const propsToExclude = ['_id', 'views', 'lastViewed', 'editId', 'shareId', 'googleId']; + for (const prop of propsToExclude) { + delete modified[prop]; + } + return modified; + }, + excludeGoogleProps : (brew)=>{ + const modified = _.clone(brew); + const propsToExclude = ['tags', 'systems', 'published', 'authors', 'owner', 'views', 'thumbnail']; + for (const prop of propsToExclude) { + delete modified[prop]; + } + return modified; + }, + excludeStubProps : (brew)=>{ + const propsToExclude = ['text', 'textBin', 'renderer', 'pageCount', 'version']; + for (const prop of propsToExclude) { + brew[prop] = undefined; + } + return brew; + }, + beforeNewSave : (account, brew)=>{ + if(!brew.title) { + brew.title = api.getGoodBrewTitle(brew.text); + } + + brew.authors = (account) ? [account.username] : []; + brew.text = api.mergeBrewText(brew); + }, + newGoogleBrew : async (account, brew, res)=>{ + const oAuth2Client = GoogleActions.authCheck(account, res); + + const newBrew = api.excludeGoogleProps(brew); + + return await GoogleActions.newGoogleBrew(oAuth2Client, newBrew); + }, + newBrew : async (req, res)=>{ + const brew = req.body; + const { saveToGoogle } = req.query; + + delete brew.editId; + delete brew.shareId; + delete brew.googleId; + + api.beforeNewSave(req.account, brew); + + const newHomebrew = new HomebrewModel(brew); + newHomebrew.editId = nanoid(12); + newHomebrew.shareId = nanoid(12); + + let googleId, saved; + if(saveToGoogle) { + googleId = await api.newGoogleBrew(req.account, newHomebrew, res) + .catch((err)=>{ + console.error(err); + res.status(err?.status || err?.response?.status || 500).send(err?.message || err); + }); + if(!googleId) return; + api.excludeStubProps(newHomebrew); + newHomebrew.googleId = googleId; + } else { + // Compress brew text to binary before saving + newHomebrew.textBin = zlib.deflateRawSync(newHomebrew.text); + // Delete the non-binary text field since it's not needed anymore + newHomebrew.text = undefined; + } + + saved = await newHomebrew.save() .catch((err)=>{ - console.error(err); - res.status(500).send(err); + console.error(err, err.toString(), err.stack); + throw `Error while creating new brew, ${err.toString()}`; }); - if(!deleted) return; + if(!saved) return; + saved = saved.toObject(); + + res.status(200).send(saved); + }, + updateBrew : async (req, res)=>{ + // Initialize brew from request and body, destructure query params, set a constant for the google id, and set the initial value for the after-save method + let brew = _.assign(req.brew, api.excludePropsFromUpdate(req.body)); + const { saveToGoogle, removeFromGoogle } = req.query; + const googleId = brew.googleId; + let afterSave = async ()=>true; + + brew.text = api.mergeBrewText(brew); + + if(brew.googleId && removeFromGoogle) { + // If the google id exists and we're removing it from google, set afterSave to delete the google brew and mark the brew's google id as undefined + afterSave = async ()=>{ + return await api.deleteGoogleBrew(req.account, googleId, brew.editId, res) + .catch((err)=>{ + console.error(err); + res.status(err?.status || err?.response?.status || 500).send(err.message || err); + }); + }; + + brew.googleId = undefined; + } else if(!brew.googleId && saveToGoogle) { + // If we don't have a google id and the user wants to save to google, create the google brew and set the google id on the brew + brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res) + .catch((err)=>{ + console.error(err); + res.status(err.status || err.response.status).send(err.message || err); + }); + if(!brew.googleId) return; + } else if(brew.googleId) { + // If the google id exists and no other actions are being performed, update the google brew + const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew)) + .catch((err)=>{ + console.error(err); + res.status(err?.response?.status || 500).send(err); + }); + if(!updated) return; + } + + if(brew.googleId) { + // If the google id exists after all those actions, exclude the props that are stored in google and aren't needed for rendering the brew items + api.excludeStubProps(brew); + } else { + // Compress brew text to binary before saving + brew.textBin = zlib.deflateRawSync(brew.text); + // Delete the non-binary text field since it's not needed anymore + brew.text = undefined; + } + brew.updatedAt = new Date(); + + if(req.account) { + brew.authors = _.uniq(_.concat(brew.authors, req.account.username)); + } + // we need the tag type change in both getBrew and here to handle the case where we don't have a stub on which to perform the modification + if(typeof brew.tags === 'string') { + brew.tags = []; + } + + // define a function to catch our save errors + const saveError = (err)=>{ + console.error(err); + res.status(err.status || 500).send(err.message || 'Unable to save brew to Homebrewery database'); + }; + let saved; + if(!brew._id) { + // if the brew does not have a stub id, create and save it, then write the new value back to the brew. + saved = await new HomebrewModel(brew).save().catch(saveError); + brew = saved?.toObject(); + } else { + // if the brew does have a stub id, update it using the stub id as the key. + saved = await HomebrewModel.updateOne({ _id: brew._id }, brew).catch(saveError); + } + if(!saved) return; + // Call and wait for afterSave to complete + const after = await afterSave(); + if(!after) return; + + res.status(200).send(brew); + }, + deleteGoogleBrew : async (account, id, editId, res)=>{ + const auth = await GoogleActions.authCheck(account, res); + await GoogleActions.deleteGoogleBrew(auth, id, editId); + return true; + }, + deleteBrew : async (req, res, next)=>{ + // Delete an orphaned stub if its Google brew doesn't exist + try { + await api.getBrew('edit')(req, res, ()=>{}); + } catch (err) { + const { id, googleId } = api.getId(req); + console.warn(`No google brew found for id ${googleId}, the stub with id ${id} will be deleted.`); + await HomebrewModel.deleteOne({ editId: id }); + return next(); + } + + let brew = req.brew; + const { googleId, editId } = brew; + const account = req.account; + const isOwner = account && (brew.authors.length === 0 || brew.authors[0] === account.username); + // If the user is the owner and the file is saved to google, mark the google brew for deletion + const shouldDeleteGoogleBrew = googleId && isOwner; + + if(brew._id) { + brew = _.assign(await HomebrewModel.findOne({ _id: brew._id }), brew); + if(account) { + // Remove current user as author + brew.authors = _.pull(brew.authors, account.username); + brew.markModified('authors'); + } + + if(brew.authors.length === 0) { + // Delete brew if there are no authors left + await brew.remove() + .catch((err)=>{ + console.error(err); + throw { status: 500, message: 'Error while removing' }; + }); + } else { + if(shouldDeleteGoogleBrew) { + // When there are still authors remaining, we delete the google brew but store the full brew in the Homebrewery database + brew.googleId = undefined; + brew.textBin = zlib.deflateRawSync(brew.text); + brew.text = undefined; + } + + // Otherwise, save the brew with updated author list + await brew.save() + .catch((err)=>{ + throw { status: 500, message: err }; + }); + } + } + if(shouldDeleteGoogleBrew) { + const deleted = await api.deleteGoogleBrew(account, googleId, editId, res) + .catch((err)=>{ + console.error(err); + res.status(500).send(err); + }); + if(!deleted) return; + } + + res.status(204).send(); } - - res.status(204).send(); }; -router.post('/api', asyncHandler(newBrew)); -router.put('/api/:id', asyncHandler(getBrew('edit', false)), asyncHandler(updateBrew)); -router.put('/api/update/:id', asyncHandler(getBrew('edit', false)), asyncHandler(updateBrew)); -router.delete('/api/:id', asyncHandler(deleteBrew)); -router.get('/api/remove/:id', asyncHandler(deleteBrew)); +router.post('/api', asyncHandler(api.newBrew)); +router.put('/api/:id', asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); +router.put('/api/update/:id', asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); +router.delete('/api/:id', asyncHandler(api.deleteBrew)); +router.get('/api/remove/:id', asyncHandler(api.deleteBrew)); -module.exports = { - homebrewApi : router, - getBrew -}; +module.exports = api; diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js new file mode 100644 index 000000000..fbf5cff0b --- /dev/null +++ b/server/homebrew.api.spec.js @@ -0,0 +1,377 @@ +/* eslint-disable max-lines */ + +describe('Tests for api', ()=>{ + let api; + let google; + let model; + let hbBrew; + let googleBrew; + + beforeEach(()=>{ + google = require('./googleActions.js'); + model = require('./homebrew.model.js').model; + + jest.mock('./googleActions.js'); + jest.mock('./homebrew.model.js'); + + api = require('./homebrew.api'); + + hbBrew = { + text : `brew text`, + style : 'hello yes i am css', + title : 'some title', + description : 'this is a description', + tags : ['something', 'fun'], + systems : ['D&D 5e'], + renderer : 'v3', + theme : 'phb', + published : true, + authors : ['1', '2'], + owner : '1', + thumbnail : '', + _id : 'mongoid', + editId : 'abcdefg', + shareId : 'hijklmnop', + views : 1, + lastViewed : new Date(), + version : 1, + pageCount : 1, + textBin : '' + }; + googleBrew = { + ...hbBrew, + googleId : '12345' + }; + }); + + afterEach(()=>{ + jest.restoreAllMocks(); + }); + + describe('getId', ()=>{ + it('should return only id if google id is not present', ()=>{ + const { id, googleId } = api.getId({ + params : { + id : 'abcdefgh' + } + }); + + expect(id).toEqual('abcdefgh'); + expect(googleId).toBeUndefined(); + }); + + it('should return id and google id from request body', ()=>{ + const { id, googleId } = api.getId({ + params : { + id : 'abcdefgh' + }, + body : { + googleId : '12345' + } + }); + + expect(id).toEqual('abcdefgh'); + expect(googleId).toEqual('12345'); + }); + + it('should return id and google id params', ()=>{ + const { id, googleId } = api.getId({ + params : { + id : '123456789012abcdefghijkl' + } + }); + + expect(id).toEqual('abcdefghijkl'); + expect(googleId).toEqual('123456789012'); + }); + }); + + describe('getBrew', ()=>{ + const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew })); + const notFoundError = 'Brew not found in Homebrewery database or Google Drive'; + + it('returns middleware', ()=>{ + const getFn = api.getBrew('share'); + expect(getFn).toBeInstanceOf(Function); + }); + + it('should fetch from mongoose', async ()=>{ + const testBrew = { title: 'test brew', authors: [] }; + api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); + model.get = jest.fn(()=>toBrewPromise(testBrew)); + + const fn = api.getBrew('share', true); + const req = { brew: {} }; + const next = jest.fn(); + await fn(req, null, next); + + expect(req.brew).toEqual(testBrew); + expect(next).toHaveBeenCalled(); + expect(api.getId).toHaveBeenCalledWith(req); + expect(model.get).toHaveBeenCalledWith({ shareId: '1' }); + }); + + it('should handle mongoose error', async ()=>{ + api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); + model.get = jest.fn(()=>new Promise((_, rej)=>rej('Unable to find brew'))); + + const fn = api.getBrew('share', true); + const req = { brew: {} }; + const next = jest.fn(); + let err; + try { + await fn(req, null, next); + } catch (e) { + err = e; + } + + expect(err).toEqual(notFoundError); + expect(req.brew).toEqual({}); + expect(next).not.toHaveBeenCalled(); + expect(api.getId).toHaveBeenCalledWith(req); + expect(model.get).toHaveBeenCalledWith({ shareId: '1' }); + }); + + it('changes tags from string to array', async ()=>{ + const testBrew = { title: 'test brew', authors: [], tags: 'tag' }; + api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); + model.get = jest.fn(()=>toBrewPromise(testBrew)); + + const fn = api.getBrew('share', true); + const req = { brew: {} }; + const next = jest.fn(); + await fn(req, null, next); + + expect(req.brew.tags).toEqual([]); + expect(next).toHaveBeenCalled(); + }); + + it('throws if invalid author', async ()=>{ + api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); + model.get = jest.fn(()=>toBrewPromise({ title: 'test brew', authors: ['a'] })); + + const fn = api.getBrew('edit', true); + const req = { brew: {} }; + + let err; + try { + await fn(req, null, null); + } catch (e) { + err = e; + } + + expect(err).toEqual('Current logged in user does not have access to this brew.'); + }); + + it('does not throw if no authors', async ()=>{ + api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); + model.get = jest.fn(()=>toBrewPromise({ title: 'test brew', authors: [] })); + + const fn = api.getBrew('edit', true); + const req = { brew: {} }; + const next = jest.fn(); + await fn(req, null, next); + + expect(next).toHaveBeenCalled(); + }); + + it('does not throw if valid author', async ()=>{ + api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); + model.get = jest.fn(()=>toBrewPromise({ title: 'test brew', authors: ['a'] })); + + const fn = api.getBrew('edit', true); + const req = { brew: {}, account: { username: 'a' } }; + const next = jest.fn(); + await fn(req, null, next); + + expect(next).toHaveBeenCalled(); + }); + + it('fetches google brew if needed', async()=>{ + const stubBrew = { title: 'test brew', authors: ['a'] }; + const googleBrew = { title: 'test google brew', text: 'brew text' }; + api.getId = jest.fn(()=>({ id: '1', googleId: '2' })); + model.get = jest.fn(()=>toBrewPromise(stubBrew)); + google.getGoogleBrew = jest.fn(()=>new Promise((res)=>res(googleBrew))); + + const fn = api.getBrew('share', true); + const req = { brew: {} }; + const next = jest.fn(); + await fn(req, null, next); + + expect(req.brew).toEqual({ + title : 'test google brew', + authors : ['a'], + text : 'brew text', + stubbed : true + }); + expect(next).toHaveBeenCalled(); + expect(api.getId).toHaveBeenCalledWith(req); + expect(model.get).toHaveBeenCalledWith({ shareId: '1' }); + expect(google.getGoogleBrew).toHaveBeenCalledWith('2', '1', 'share'); + }); + }); + + describe('mergeBrewText', ()=>{ + it('should set metadata and no style if it is not present', ()=>{ + const result = api.mergeBrewText({ + text : `brew`, + title : 'some title', + description : 'this is a description', + tags : ['something', 'fun'], + systems : ['D&D 5e'], + renderer : 'v3', + theme : 'phb', + googleId : '12345' + }); + + expect(result).toEqual(`\`\`\`metadata +title: some title +description: this is a description +tags: + - something + - fun +systems: + - D&D 5e +renderer: v3 +theme: phb + +\`\`\` + +brew`); + }); + + it('should set metadata and style', ()=>{ + const result = api.mergeBrewText({ + text : `brew`, + style : 'hello yes i am css', + title : 'some title', + description : 'this is a description', + tags : ['something', 'fun'], + systems : ['D&D 5e'], + renderer : 'v3', + theme : 'phb', + googleId : '12345' + }); + + expect(result).toEqual(`\`\`\`metadata +title: some title +description: this is a description +tags: + - something + - fun +systems: + - D&D 5e +renderer: v3 +theme: phb + +\`\`\` + +\`\`\`css +hello yes i am css +\`\`\` + +brew`); + }); + }); + + describe('exclusion methods', ()=>{ + it('excludePropsFromUpdate removes the correct keys', ()=>{ + const sent = Object.assign({}, googleBrew); + const result = api.excludePropsFromUpdate(sent); + + expect(sent).toEqual(googleBrew); + expect(result._id).toBeUndefined(); + expect(result.views).toBeUndefined(); + expect(result.lastViewed).toBeUndefined(); + expect(result.editId).toBeUndefined(); + expect(result.shareId).toBeUndefined(); + expect(result.googleId).toBeUndefined(); + }); + + it('excludeGoogleProps removes the correct keys', ()=>{ + const sent = Object.assign({}, googleBrew); + const result = api.excludeGoogleProps(sent); + + expect(sent).toEqual(googleBrew); + expect(result.tags).toBeUndefined(); + expect(result.systems).toBeUndefined(); + expect(result.published).toBeUndefined(); + expect(result.authors).toBeUndefined(); + expect(result.owner).toBeUndefined(); + expect(result.views).toBeUndefined(); + expect(result.thumbnail).toBeUndefined(); + }); + + it('excludeStubProps removes the correct keys from the original object', ()=>{ + const sent = Object.assign({}, googleBrew); + const result = api.excludeStubProps(sent); + + expect(sent).not.toEqual(googleBrew); + expect(result.text).toBeUndefined(); + expect(result.textBin).toBeUndefined(); + expect(result.renderer).toBeUndefined(); + expect(result.pageCount).toBeUndefined(); + expect(result.version).toBeUndefined(); + }); + }); + + describe('beforeNewSave', ()=>{ + it('sets the title if none', ()=>{ + const brew = { + ...hbBrew, + title : undefined + }; + api.beforeNewSave({}, brew); + + expect(brew.title).toEqual('brew text'); + }); + + it('does not set the title if present', ()=>{ + const brew = { + ...hbBrew, + title : 'test' + }; + api.beforeNewSave({}, brew); + + expect(brew.title).toEqual('test'); + }); + + it('does not set authors if account missing username', ()=>{ + api.beforeNewSave({}, hbBrew); + + expect(hbBrew.authors).toEqual([]); + }); + + it('sets authors if account has username', ()=>{ + api.beforeNewSave({ username: 'hi' }, hbBrew); + + expect(hbBrew.authors).toEqual(['hi']); + }); + + it('merges brew text', ()=>{ + api.mergeBrewText = jest.fn(()=>'merged'); + api.beforeNewSave({}, hbBrew); + + expect(api.mergeBrewText).toHaveBeenCalled(); + expect(hbBrew.text).toEqual('merged'); + }); + }); + + describe('newGoogleBrew', ()=>{ + it('should call the correct methods', ()=>{ + google.authCheck = jest.fn().mockImplementation(()=>'client'); + api.excludeGoogleProps = jest.fn(()=>'newBrew'); + google.newGoogleBrew = jest.fn(()=>'id'); + + const acct = { username: 'test' }; + const brew = { title: 'test title' }; + const res = { send: jest.fn(()=>{}) }; + api.newGoogleBrew(acct, brew, res); + + expect(google.authCheck).toHaveBeenCalledWith(acct, res); + expect(api.excludeGoogleProps).toHaveBeenCalledWith(brew); + expect(google.newGoogleBrew).toHaveBeenCalledWith('client', 'newBrew'); + }); + }); +}); From da9e20e96f0e7e3904c2668eabfd00e5dfb0cc59 Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Fri, 23 Dec 2022 00:11:46 -0600 Subject: [PATCH 02/10] add more tests and merge from main --- server/homebrew.api.js | 2 +- server/homebrew.api.spec.js | 154 +++++++++++++++++++++++++++++++++--- 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 6722012be..509b8fdd2 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -128,7 +128,7 @@ If you believe you should have access to this brew, ask the file owner to invite return modified; }, excludeStubProps : (brew)=>{ - const propsToExclude = ['text', 'textBin', 'renderer', 'pageCount', 'version']; + const propsToExclude = ['text', 'textBin', 'renderer', 'pageCount']; for (const prop of propsToExclude) { brew[prop] = undefined; } diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index fbf5cff0b..89c69f56b 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -1,18 +1,44 @@ /* eslint-disable max-lines */ +const modelBrew = (brew, saveFunc = async function() { + return { ...this, _id: '1' }; +})=>({ + ...brew, + save : saveFunc, + toObject : function() { + delete this.save; + delete this.toObject; + return this; + } +}); +const modelBrewThrow = (brew)=>modelBrew(brew, async function() { + throw 'err'; +}); + describe('Tests for api', ()=>{ let api; let google; let model; let hbBrew; let googleBrew; + let res; beforeEach(()=>{ google = require('./googleActions.js'); model = require('./homebrew.model.js').model; jest.mock('./googleActions.js'); + google.authCheck = jest.fn(()=>'client'); + google.newGoogleBrew = jest.fn(()=>'id'); + google.deleteGoogleBrew = jest.fn(()=>true); + jest.mock('./homebrew.model.js'); + model.mockImplementation((brew)=>modelBrew(brew)); + + res = { + status : jest.fn(()=>res), + send : jest.fn(()=>{}) + }; api = require('./homebrew.api'); @@ -115,7 +141,7 @@ describe('Tests for api', ()=>{ api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); model.get = jest.fn(()=>new Promise((_, rej)=>rej('Unable to find brew'))); - const fn = api.getBrew('share', true); + const fn = api.getBrew('share', false); const req = { brew: {} }; const next = jest.fn(); let err; @@ -160,7 +186,9 @@ describe('Tests for api', ()=>{ err = e; } - expect(err).toEqual('Current logged in user does not have access to this brew.'); + expect(err).toEqual(`The current logged in user does not have editor access to this brew. + +If you believe you should have access to this brew, ask the file owner to invite you as an author by opening the brew, viewing the Properties tab, and adding your username to the "invited authors" list. You can then try to access this document again.`); }); it('does not throw if no authors', async ()=>{ @@ -194,16 +222,28 @@ describe('Tests for api', ()=>{ model.get = jest.fn(()=>toBrewPromise(stubBrew)); google.getGoogleBrew = jest.fn(()=>new Promise((res)=>res(googleBrew))); - const fn = api.getBrew('share', true); + const fn = api.getBrew('share', false); const req = { brew: {} }; const next = jest.fn(); await fn(req, null, next); expect(req.brew).toEqual({ - title : 'test google brew', - authors : ['a'], - text : 'brew text', - stubbed : true + title : 'test google brew', + authors : ['a'], + text : 'brew text', + stubbed : true, + description : '', + editId : null, + pageCount : 1, + published : true, + renderer : 'legacy', + shareId : null, + systems : [], + tags : [], + theme : '5ePHB', + thumbnail : '', + textBin : undefined, + version : undefined }); expect(next).toHaveBeenCalled(); expect(api.getId).toHaveBeenCalledWith(req); @@ -301,6 +341,7 @@ brew`); expect(result.owner).toBeUndefined(); expect(result.views).toBeUndefined(); expect(result.thumbnail).toBeUndefined(); + expect(result.version).toBeUndefined(); }); it('excludeStubProps removes the correct keys from the original object', ()=>{ @@ -312,7 +353,6 @@ brew`); expect(result.textBin).toBeUndefined(); expect(result.renderer).toBeUndefined(); expect(result.pageCount).toBeUndefined(); - expect(result.version).toBeUndefined(); }); }); @@ -360,13 +400,10 @@ brew`); describe('newGoogleBrew', ()=>{ it('should call the correct methods', ()=>{ - google.authCheck = jest.fn().mockImplementation(()=>'client'); api.excludeGoogleProps = jest.fn(()=>'newBrew'); - google.newGoogleBrew = jest.fn(()=>'id'); const acct = { username: 'test' }; const brew = { title: 'test title' }; - const res = { send: jest.fn(()=>{}) }; api.newGoogleBrew(acct, brew, res); expect(google.authCheck).toHaveBeenCalledWith(acct, res); @@ -374,4 +411,99 @@ brew`); expect(google.newGoogleBrew).toHaveBeenCalledWith('client', 'newBrew'); }); }); + + describe('newBrew', ()=>{ + it('should set up a default brew via Homebrew model', async ()=>{ + await api.newBrew({ body: { text: 'asdf' }, query: {}, account: { username: 'test user' } }, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ + _id : '1', + authors : ['test user'], + description : '', + editId : expect.any(String), + pageCount : 1, + published : false, + renderer : 'V3', + shareId : expect.any(String), + systems : [], + tags : [], + text : undefined, + textBin : expect.objectContaining({}), + theme : '5ePHB', + thumbnail : '', + title : 'asdf', + }); + }); + + it('should remove edit/share/google ids', async ()=>{ + await api.newBrew({ body: { editId: '1234', shareId: '1234', googleId: '1234', text: 'asdf', title: '' }, query: {} }, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalled(); + const sent = res.send.mock.calls[0][0]; + expect(sent.editId).not.toEqual('1234'); + expect(sent.shareId).not.toEqual('1234'); + expect(sent.googleId).toBeUndefined(); + }); + + it('should handle mongo error', async ()=>{ + model.mockImplementation((brew)=>modelBrewThrow(brew)); + + let err; + try { + await api.newBrew({ body: { editId: '1234', shareId: '1234', googleId: '1234', text: 'asdf', title: '' }, query: {} }, res); + } catch (e) { + err = e; + } + + expect(res.send).not.toHaveBeenCalled(); + expect(err).not.toBeUndefined(); + }); + + it('should save to google if requested', async()=>{ + await api.newBrew({ body: { text: 'asdf', title: '' }, query: { saveToGoogle: true }, account: { username: 'test user' } }, res); + + expect(google.newGoogleBrew).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ + _id : '1', + authors : ['test user'], + description : '', + editId : expect.any(String), + pageCount : undefined, + published : false, + renderer : undefined, + shareId : expect.any(String), + googleId : expect.any(String), + systems : [], + tags : [], + text : undefined, + textBin : undefined, + theme : '5ePHB', + thumbnail : '', + title : 'asdf', + }); + }); + + it('should handle google error', async()=>{ + google.newGoogleBrew = jest.fn(()=>{ + throw 'err'; + }); + await api.newBrew({ body: { text: 'asdf', title: '' }, query: { saveToGoogle: true }, account: { username: 'test user' } }, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith('err'); + }); + }); + + describe('deleteGoogleBrew', ()=>{ + it('should check auth and delete brew', async ()=>{ + const result = await api.deleteGoogleBrew({ username: 'test user' }, 'id', 'editId', res); + + expect(result).toBe(true); + expect(google.authCheck).toHaveBeenCalledWith({ username: 'test user' }, expect.objectContaining({})); + expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId'); + }); + }); }); From 64e7fe34228ab00619bd0e626311d958535af9a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Jan 2023 03:01:25 +0000 Subject: [PATCH 03/10] Bump @babel/core from 7.20.7 to 7.20.12 Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.20.7 to 7.20.12. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.20.12/packages/babel-core) --- updated-dependencies: - dependency-name: "@babel/core" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 66 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdc5c036c..796e57bee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.20.7", + "@babel/core": "^7.20.12", "@babel/plugin-transform-runtime": "^7.19.6", "@babel/preset-env": "^7.19.4", "@babel/preset-react": "^7.18.6", @@ -1452,24 +1452,24 @@ } }, "node_modules/@babel/core": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.7.tgz", - "integrity": "sha512-t1ZjCluspe5DW24bn2Rr1CDb2v9rn/hROtg9a2tmd0+QYf4bsloYfLQzjG4qHPNMhWtKdGC33R5AxGR2Af2cBw==", + "version": "7.20.12", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz", + "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==", "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.18.6", "@babel/generator": "^7.20.7", "@babel/helper-compilation-targets": "^7.20.7", - "@babel/helper-module-transforms": "^7.20.7", + "@babel/helper-module-transforms": "^7.20.11", "@babel/helpers": "^7.20.7", "@babel/parser": "^7.20.7", "@babel/template": "^7.20.7", - "@babel/traverse": "^7.20.7", + "@babel/traverse": "^7.20.12", "@babel/types": "^7.20.7", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.1", + "json5": "^2.2.2", "semver": "^6.3.0" }, "engines": { @@ -1676,9 +1676,9 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.7.tgz", - "integrity": "sha512-FNdu7r67fqMUSVuQpFQGE6BPdhJIhitoxhGzDbAXNcA07uoVG37fOiMk3OSV8rEICuyG6t8LGkd9EE64qIEoIA==", + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz", + "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==", "dependencies": { "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-module-imports": "^7.18.6", @@ -1686,7 +1686,7 @@ "@babel/helper-split-export-declaration": "^7.18.6", "@babel/helper-validator-identifier": "^7.19.1", "@babel/template": "^7.20.7", - "@babel/traverse": "^7.20.7", + "@babel/traverse": "^7.20.10", "@babel/types": "^7.20.7" }, "engines": { @@ -3047,9 +3047,9 @@ } }, "node_modules/@babel/traverse": { - "version": "7.20.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.8.tgz", - "integrity": "sha512-/RNkaYDeCy4MjyV70+QkSHhxbvj2JO/5Ft2Pa880qJOG8tWrqcT/wXUuCCv43yogfqPzHL77Xu101KQPf4clnQ==", + "version": "7.20.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.12.tgz", + "integrity": "sha512-MsIbFN0u+raeja38qboyF8TIT7K0BFzz/Yd/77ta4MsUsmP2RAnidIlwq7d5HFQrH/OZJecGV6B71C4zAgpoSQ==", "dependencies": { "@babel/code-frame": "^7.18.6", "@babel/generator": "^7.20.7", @@ -9770,9 +9770,9 @@ "dev": true }, "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": { "json5": "lib/cli.js" }, @@ -17777,24 +17777,24 @@ "integrity": "sha512-KZXo2t10+/jxmkhNXc7pZTqRvSOIvVv/+lJwHS+B2rErwOyjuVRh60yVpb7liQ1U5t7lLJ1bz+t8tSypUZdm0g==" }, "@babel/core": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.7.tgz", - "integrity": "sha512-t1ZjCluspe5DW24bn2Rr1CDb2v9rn/hROtg9a2tmd0+QYf4bsloYfLQzjG4qHPNMhWtKdGC33R5AxGR2Af2cBw==", + "version": "7.20.12", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz", + "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==", "requires": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.18.6", "@babel/generator": "^7.20.7", "@babel/helper-compilation-targets": "^7.20.7", - "@babel/helper-module-transforms": "^7.20.7", + "@babel/helper-module-transforms": "^7.20.11", "@babel/helpers": "^7.20.7", "@babel/parser": "^7.20.7", "@babel/template": "^7.20.7", - "@babel/traverse": "^7.20.7", + "@babel/traverse": "^7.20.12", "@babel/types": "^7.20.7", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.1", + "json5": "^2.2.2", "semver": "^6.3.0" } }, @@ -17947,9 +17947,9 @@ } }, "@babel/helper-module-transforms": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.7.tgz", - "integrity": "sha512-FNdu7r67fqMUSVuQpFQGE6BPdhJIhitoxhGzDbAXNcA07uoVG37fOiMk3OSV8rEICuyG6t8LGkd9EE64qIEoIA==", + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz", + "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==", "requires": { "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-module-imports": "^7.18.6", @@ -17957,7 +17957,7 @@ "@babel/helper-split-export-declaration": "^7.18.6", "@babel/helper-validator-identifier": "^7.19.1", "@babel/template": "^7.20.7", - "@babel/traverse": "^7.20.7", + "@babel/traverse": "^7.20.10", "@babel/types": "^7.20.7" } }, @@ -18847,9 +18847,9 @@ } }, "@babel/traverse": { - "version": "7.20.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.8.tgz", - "integrity": "sha512-/RNkaYDeCy4MjyV70+QkSHhxbvj2JO/5Ft2Pa880qJOG8tWrqcT/wXUuCCv43yogfqPzHL77Xu101KQPf4clnQ==", + "version": "7.20.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.12.tgz", + "integrity": "sha512-MsIbFN0u+raeja38qboyF8TIT7K0BFzz/Yd/77ta4MsUsmP2RAnidIlwq7d5HFQrH/OZJecGV6B71C4zAgpoSQ==", "requires": { "@babel/code-frame": "^7.18.6", "@babel/generator": "^7.20.7", @@ -24053,9 +24053,9 @@ "dev": true }, "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==" + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" }, "jsonfile": { "version": "6.1.0", diff --git a/package.json b/package.json index b3e3c8574..e93b0fc2d 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ ] }, "dependencies": { - "@babel/core": "^7.20.7", + "@babel/core": "^7.20.12", "@babel/plugin-transform-runtime": "^7.19.6", "@babel/preset-env": "^7.19.4", "@babel/preset-react": "^7.18.6", From 387c468d84c9d015a0ad94452c113bf63db8b520 Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Thu, 5 Jan 2023 22:56:54 -0600 Subject: [PATCH 04/10] add tests for deletion and address PR feedback --- server/homebrew.api.js | 1 - server/homebrew.api.spec.js | 263 +++++++++++++++++++++++++++++++++--- 2 files changed, 246 insertions(+), 18 deletions(-) diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 24db4c4f2..3bb7221ec 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -304,7 +304,6 @@ If you believe you should have access to this brew, ask the file owner to invite if(account) { // Remove current user as author brew.authors = _.pull(brew.authors, account.username); - brew.markModified('authors'); } if(brew.authors.length === 0) { diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index 89c69f56b..cbd844496 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -1,19 +1,6 @@ /* eslint-disable max-lines */ -const modelBrew = (brew, saveFunc = async function() { - return { ...this, _id: '1' }; -})=>({ - ...brew, - save : saveFunc, - toObject : function() { - delete this.save; - delete this.toObject; - return this; - } -}); -const modelBrewThrow = (brew)=>modelBrew(brew, async function() { - throw 'err'; -}); + describe('Tests for api', ()=>{ let api; @@ -23,7 +10,31 @@ describe('Tests for api', ()=>{ let googleBrew; let res; + let modelBrew; + let saveFunc; + let removeFunc; + let saved; + beforeEach(()=>{ + saved = undefined; + saveFunc = jest.fn(async function() { + saved = { ...this, _id: '1' }; + return saved; + }); + removeFunc = jest.fn(async function() {}); + + modelBrew = (brew)=>({ + ...brew, + save : saveFunc, + remove : removeFunc, + toObject : function() { + delete this.save; + delete this.toObject; + delete this.remove; + return this; + } + }); + google = require('./googleActions.js'); model = require('./homebrew.model.js').model; @@ -100,7 +111,7 @@ describe('Tests for api', ()=>{ expect(googleId).toEqual('12345'); }); - it('should return id and google id params', ()=>{ + it('should return id and google id from params', ()=>{ const { id, googleId } = api.getId({ params : { id : '123456789012abcdefghijkl' @@ -201,6 +212,8 @@ If you believe you should have access to this brew, ask the file owner to invite await fn(req, null, next); expect(next).toHaveBeenCalled(); + expect(req.brew.title).toEqual('test brew'); + expect(req.brew.authors).toEqual([]); }); it('does not throw if valid author', async ()=>{ @@ -213,6 +226,8 @@ If you believe you should have access to this brew, ask the file owner to invite await fn(req, null, next); expect(next).toHaveBeenCalled(); + expect(req.brew.title).toEqual('test brew'); + expect(req.brew.authors).toEqual(['a']); }); it('fetches google brew if needed', async()=>{ @@ -367,7 +382,7 @@ brew`); expect(brew.title).toEqual('brew text'); }); - it('does not set the title if present', ()=>{ + it('does not override the title if present', ()=>{ const brew = { ...hbBrew, title : 'test' @@ -448,7 +463,10 @@ brew`); }); it('should handle mongo error', async ()=>{ - model.mockImplementation((brew)=>modelBrewThrow(brew)); + saveFunc = jest.fn(async function() { + throw 'err'; + }); + model.mockImplementation((brew)=>modelBrew(brew)); let err; try { @@ -506,4 +524,215 @@ brew`); expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId'); }); }); + + describe('deleteBrew', ()=>{ + it('should handle case where fetching the brew returns an error', async ()=>{ + api.getBrew = jest.fn(()=>async ()=>{ throw 'err'; }); + api.getId = jest.fn(()=>({ id: '1', googleId: '2' })); + model.deleteOne = jest.fn(async ()=>{}); + const next = jest.fn(()=>{}); + + await api.deleteBrew(null, null, next); + + expect(next).toHaveBeenCalled(); + expect(model.deleteOne).toHaveBeenCalledWith({ editId: '1' }); + }); + + it('should delete if no authors', async ()=>{ + const brew = { + ...hbBrew, + authors : [] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + const req = {}; + + await api.deleteBrew(req, res); + + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).toHaveBeenCalled(); + }); + + it('should throw on delete error', async ()=>{ + const brew = { + ...hbBrew, + authors : [] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + removeFunc = jest.fn(async ()=>{ throw 'err'; }); + const req = {}; + + let err; + try { + await api.deleteBrew(req, res); + } catch (e) { + err = e; + } + + expect(err).not.toBeUndefined(); + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).toHaveBeenCalled(); + }); + + it('should delete when one author', async ()=>{ + const brew = { + ...hbBrew, + authors : ['test'] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + const req = { account: { username: 'test' } }; + + await api.deleteBrew(req, res); + + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).toHaveBeenCalled(); + }); + + it('should remove one author when multiple present', async ()=>{ + const brew = { + ...hbBrew, + authors : ['test', 'test2'] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + const req = { account: { username: 'test' } }; + + await api.deleteBrew(req, res); + + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).not.toHaveBeenCalled(); + expect(saveFunc).toHaveBeenCalled(); + expect(saved.authors).toEqual(['test2']); + }); + + it('should handle save error', async ()=>{ + const brew = { + ...hbBrew, + authors : ['test', 'test2'] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + saveFunc = jest.fn(async ()=>{ throw 'err'; }); + const req = { account: { username: 'test' } }; + + let err; + try { + await api.deleteBrew(req, res); + } catch (e) { + err = e; + } + + expect(err).not.toBeUndefined(); + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).not.toHaveBeenCalled(); + expect(saveFunc).toHaveBeenCalled(); + }); + + it('should delete google brew', async ()=>{ + const brew = { + ...googleBrew, + authors : ['test'] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + api.deleteGoogleBrew = jest.fn(async ()=>true); + const req = { account: { username: 'test' } }; + + await api.deleteBrew(req, res); + + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).toHaveBeenCalled(); + expect(api.deleteGoogleBrew).toHaveBeenCalled(); + }); + + it('should handle google brew delete error', async ()=>{ + const brew = { + ...googleBrew, + authors : ['test'] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + api.deleteGoogleBrew = jest.fn(async ()=>{ + throw 'err'; + }); + const req = { account: { username: 'test' } }; + + await api.deleteBrew(req, res); + + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).toHaveBeenCalled(); + expect(api.deleteGoogleBrew).toHaveBeenCalled(); + }); + + it('should delete google brew and retain stub when multiple authors and owner request deletion', async ()=>{ + const brew = { + ...googleBrew, + authors : ['test', 'test2'] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + api.deleteGoogleBrew = jest.fn(async ()=>true); + const req = { account: { username: 'test' } }; + + await api.deleteBrew(req, res); + + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).not.toHaveBeenCalled(); + expect(api.deleteGoogleBrew).toHaveBeenCalled(); + expect(saveFunc).toHaveBeenCalled(); + expect(saved.authors).toEqual(['test2']); + expect(saved.googleId).toEqual(undefined); + expect(saved.text).toEqual(undefined); + expect(saved.textBin).not.toEqual(undefined); + }); + + it('should retain google brew and update stub when multiple authors and extra author requests deletion', async ()=>{ + const brew = { + ...googleBrew, + authors : ['test', 'test2'] + }; + api.getBrew = jest.fn(()=>async (req)=>{ + req.brew = brew; + }); + model.findOne = jest.fn(async ()=>modelBrew(brew)); + api.deleteGoogleBrew = jest.fn(async ()=>true); + const req = { account: { username: 'test2' } }; + + await api.deleteBrew(req, res); + + expect(api.getBrew).toHaveBeenCalled(); + expect(model.findOne).toHaveBeenCalled(); + expect(removeFunc).not.toHaveBeenCalled(); + expect(api.deleteGoogleBrew).not.toHaveBeenCalled(); + expect(saveFunc).toHaveBeenCalled(); + expect(saved.authors).toEqual(['test']); + expect(saved.googleId).toEqual(brew.googleId); + }); + }); }); From a54ebabf534b2b9504e8f7dcdb1308e15d00d236 Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Thu, 5 Jan 2023 23:10:25 -0600 Subject: [PATCH 05/10] fix tests after merge from master --- server/homebrew.api.spec.js | 57 +++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index cbd844496..68355cc1a 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -1,7 +1,5 @@ /* eslint-disable max-lines */ - - describe('Tests for api', ()=>{ let api; let google; @@ -170,7 +168,7 @@ describe('Tests for api', ()=>{ }); it('changes tags from string to array', async ()=>{ - const testBrew = { title: 'test brew', authors: [], tags: 'tag' }; + const testBrew = { title: 'test brew', authors: [], tags: '' }; api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); model.get = jest.fn(()=>toBrewPromise(testBrew)); @@ -248,17 +246,22 @@ If you believe you should have access to this brew, ask the file owner to invite text : 'brew text', stubbed : true, description : '', - editId : null, + editId : undefined, pageCount : 1, - published : true, + published : false, renderer : 'legacy', - shareId : null, + shareId : undefined, systems : [], tags : [], theme : '5ePHB', thumbnail : '', textBin : undefined, - version : undefined + version : undefined, + createdAt : undefined, + gDrive : false, + style : undefined, + trashed : false, + updatedAt : undefined }); expect(next).toHaveBeenCalled(); expect(api.getId).toHaveBeenCalledWith(req); @@ -433,21 +436,26 @@ brew`); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ - _id : '1', - authors : ['test user'], - description : '', - editId : expect.any(String), - pageCount : 1, - published : false, - renderer : 'V3', - shareId : expect.any(String), - systems : [], - tags : [], - text : undefined, - textBin : expect.objectContaining({}), - theme : '5ePHB', - thumbnail : '', - title : 'asdf', + _id : '1', + authors : ['test user'], + createdAt : undefined, + description : '', + editId : expect.any(String), + gDrive : false, + pageCount : 1, + published : false, + renderer : 'V3', + shareId : expect.any(String), + style : undefined, + systems : [], + tags : [], + text : undefined, + textBin : expect.objectContaining({}), + theme : '5ePHB', + thumbnail : '', + title : 'asdf', + trashed : false, + updatedAt : undefined }); }); @@ -487,13 +495,16 @@ brew`); expect(res.send).toHaveBeenCalledWith({ _id : '1', authors : ['test user'], + createdAt : undefined, description : '', editId : expect.any(String), + gDrive : false, pageCount : undefined, published : false, renderer : undefined, shareId : expect.any(String), googleId : expect.any(String), + style : undefined, systems : [], tags : [], text : undefined, @@ -501,6 +512,8 @@ brew`); theme : '5ePHB', thumbnail : '', title : 'asdf', + trashed : false, + updatedAt : undefined }); }); From c0b9cd951e02d87344aba21fe75745df4f266cff Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Thu, 5 Jan 2023 23:29:43 -0600 Subject: [PATCH 06/10] update circleci test config to run test coverage --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 38b3c8249..070f05d4d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -58,6 +58,9 @@ jobs: - run: name: Test - Basic command: npm run test:basic + - run: + name: Test - Coverage + command: npm run test:coverage - run: name: Test - Mustache Spans command: npm run test:mustache-span From 0720ac6a15f6af3b30bb6a27de98e05e6ad0266e Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Thu, 5 Jan 2023 23:29:58 -0600 Subject: [PATCH 07/10] update changelog --- changelog.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/changelog.md b/changelog.md index ed3c984db..adb2d73ce 100644 --- a/changelog.md +++ b/changelog.md @@ -57,6 +57,13 @@ pre { ## changelog For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). +### v3.6.0 +{{taskList +##### Jeddai + +* [x] Add unit tests with full coverage for the Homebrewery API +}} + ### Friday 23/12/2022 - v3.5.0 {{taskList From 4b3edf053f24fea5d25f7faec42e5a8422fd8504 Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Thu, 5 Jan 2023 23:30:20 -0600 Subject: [PATCH 08/10] add coverage thresholds and coverage command --- package.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b3e3c8574..75eac362c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0", "verify": "npm run lint && npm test", "test": "jest", + "test:coverage": "jest --coverage", "test:dev": "jest --verbose --watch", "test:basic": "jest tests/markdown/basic.test.js --verbose", "test:mustache-span": "jest tests/markdown/mustache-span.test.js --verbose", @@ -39,7 +40,21 @@ "mode_modules", "shared", "server" - ] + ], + "coverageThreshold" : { + "global" : { + "statements" : 25, + "branches" : 10, + "functions" : 22, + "lines" : 25 + }, + "server/homebrew.api.js" : { + "statements" : 71, + "branches" : 54, + "functions" : 66, + "lines" : 73 + } + } }, "babel": { "presets": [ From 93d6d1ac6a8bcbb3ffc3af350cc29decfb66d7dd Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Fri, 6 Jan 2023 11:48:00 -0600 Subject: [PATCH 09/10] add unit test command --- .circleci/config.yml | 3 +++ package.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 070f05d4d..feeb8969a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -55,6 +55,9 @@ jobs: at: . # run tests! + - run: + name: Test - API Unit Tests + command: npm run test:unit - run: name: Test - Basic command: npm run test:basic diff --git a/package.json b/package.json index 75eac362c..57cf0b0d9 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0", "verify": "npm run lint && npm test", "test": "jest", - "test:coverage": "jest --coverage", + "test:api-unit": "jest server/*.spec.js --verbose", + "test:coverage": "jest --coverage --silent", "test:dev": "jest --verbose --watch", "test:basic": "jest tests/markdown/basic.test.js --verbose", "test:mustache-span": "jest tests/markdown/mustache-span.test.js --verbose", From 67a76f9d860ee4c6c20ee7ff7cb50400fd83e033 Mon Sep 17 00:00:00 2001 From: Charlie Humphreys Date: Fri, 6 Jan 2023 11:53:38 -0600 Subject: [PATCH 10/10] adjust circleci command for API unit tests --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index feeb8969a..3c48e7d34 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -57,7 +57,7 @@ jobs: # run tests! - run: name: Test - API Unit Tests - command: npm run test:unit + command: npm run test:api-unit - run: name: Test - Basic command: npm run test:basic