├── .babelrc ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── __test__ ├── akismet.spec.js ├── e2e.spec.js ├── fixtures │ ├── comment.js │ ├── db.js │ └── moderation-api │ │ ├── no-classification.json │ │ ├── no-review.json │ │ └── review.json └── is-questionable.spec.js ├── db └── index.js ├── jest-mongo.js ├── jest-setup.js ├── jest-teardown.js ├── package-lock.json ├── package.json ├── sampledotenv └── src ├── Id └── index.js ├── comment ├── comment.js ├── comment.spec.js ├── index.js └── source.js ├── controllers ├── delete-comment.js ├── get-comments.js ├── index.js ├── not-found.js ├── patch-comment.js ├── patch-comment.spec.js ├── post-comment.js └── post-comment.spec.js ├── data-access ├── comments-db.js ├── comments-db.spec.js └── index.js ├── express-callback └── index.js ├── index.js ├── is-questionable ├── index.js ├── is-questionable.js └── is-questionable.spec.js └── use-cases ├── add-comment.js ├── add-comment.spec.js ├── edit-comment.js ├── edit-comment.spec.js ├── handle-moderation.js ├── index.js ├── list-comments.js ├── list-comments.spec.js ├── remove-comment.js └── remove-comment.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": true 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | "inline-dotenv" 14 | ] 15 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'standard', 3 | env: { 4 | jest: true, 5 | node: true, 6 | mongo: true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS Junk 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | #config 18 | webpack.config.js 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled 39 | dist 40 | public 41 | *.zip 42 | 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # TypeScript v1 declaration files 52 | typings/ 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional REPL history 61 | .node_repl_history 62 | 63 | # Output of 'npm pack' 64 | *.tgz 65 | 66 | # dotenv environment variables file 67 | .env 68 | 69 | #in memory mongo 70 | globalConfigMongo.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License 3 | 4 | Copyright (c) 2019 Dev Mastery. https://devmastery.com 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DevMastery Comments Microservice API 2 | To manage comments on various Dev Mastery properties. 3 | 4 | [Using Clean Architecture for Microservice APIs in Node.js with MongoDB and Express](https://www.youtube.com/watch?v=CnailTcJV_U) 5 | 6 | > In this video we talk about Bob Martin's Clean Architecture model and I will show you how we can apply it to a Microservice built in node.js with MongoDB and Express JS - @arcdev1 7 | 8 | ## Features 9 | * XSS Protection (via [sanitize-html](https://www.npmjs.com/package/sanitize-html)) 10 | * Flags Spam (via [Akismet](https://akismet.com/)) 11 | * Flags rude or inappropriate language (English only via [Content Moderator](https://contentmoderator.cognitive.microsoft.com)) 12 | * Flags personally identifiable information (English only via [Content Moderator](https://contentmoderator.cognitive.microsoft.com)) 13 | 14 | ## Running Locally 15 | 16 | #### Prerequisites 17 | * [Git](https://git-scm.com/downloads) 18 | * [Node JS](https://nodejs.org/en/) 19 | * [Mongo DB](https://www.mongodb.com) (To use the Mongo DB interface as shown in the Mastery Monday youtube video, you need to install Mongo Compass) 20 | * [Azure Content Moderator account (free)](https://contentmoderator.cognitive.microsoft.com) 21 | * [Akismet Developer account (free)](https://akismet.com/development/api/#getting-started) 22 | 23 | 24 | #### 1. Clone the repo and install dependencies 25 | ```bash 26 | git clone 27 | cd comments-api 28 | npm i 29 | ``` 30 | 31 | #### 2. Modify the .env file 32 | Save `sampledotenv` as `.env` and then add your database and Content Moderator + Akismet API details. 33 | 34 | #### 3. Startup your MongoDB 35 | Usually this is just: `mongod` on the command line. 36 | 37 | #### 4. Start the server 38 | To run in production mode where code is transpiled by Babel into a `dist` folder and run directly in `node`: 39 | ```bash 40 | npm start 41 | ``` 42 | 43 | To run in development mode where code is run by [babel-node](https://babeljs.io/docs/en/babel-node) via [nodemon](https://nodemon.io) and re-transpiled any time there is a change: 44 | ```bash 45 | npm run dev 46 | ``` 47 | -------------------------------------------------------------------------------- /__test__/akismet.spec.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import qs from 'querystring' 3 | import makeFakeComment from './fixtures/comment' 4 | import dotenv from 'dotenv' 5 | dotenv.config() 6 | axios.defaults.validateStatus = function (status) { 7 | // Throw only if the status code is greater than or equal to 500 8 | return status < 500 9 | } 10 | describe('akismet', () => { 11 | it('works', async () => { 12 | const comment = makeFakeComment() 13 | const req = { 14 | headers: { 15 | 'Content-Type': 'application/x-www-form-urlencoded' 16 | }, 17 | url: process.env.DM_SPAM_API_URL, 18 | method: 'post', 19 | data: qs.stringify({ 20 | blog: 'https://devmastery.com', 21 | user_ip: comment.source.ip, 22 | user_agent: comment.source.browser, 23 | referrer: comment.source.referrer, 24 | comment_type: 'comment', 25 | comment_author: comment.author, 26 | comment_content: comment.text, 27 | comment_date_gmt: new Date(comment.createdOn).toISOString(), 28 | comment_post_modified_gmt: new Date(comment.modifiedOn).toISOString(), 29 | blog_lang: 'en', 30 | is_test: false 31 | }) 32 | } 33 | const res = await axios(req) 34 | expect(res.data).toBe(false) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /__test__/e2e.spec.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import commentsDb, { makeDb } from '../src/data-access' 3 | import makeFakeComment from './fixtures/comment' 4 | import dotenv from 'dotenv' 5 | dotenv.config() 6 | 7 | describe('Comments API', () => { 8 | beforeAll(() => { 9 | axios.defaults.baseURL = process.env.DM_BASE_URL + process.env.DM_API_ROOT 10 | axios.defaults.headers.common['Content-Type'] = 'application/json' 11 | axios.defaults.validateStatus = function (status) { 12 | // Throw only if the status code is greater than or equal to 500 13 | return status < 500 14 | } 15 | }) 16 | afterAll(async () => { 17 | const db = await makeDb() 18 | return db.collection('comments').drop() 19 | }) 20 | 21 | describe('adding comments', () => { 22 | // Content moderator API only allows 1 request per second. 23 | beforeEach(done => setTimeout(() => done(), 1100)) 24 | it('adds a comment to the database', async () => { 25 | const response = await axios.post( 26 | '/comments/', 27 | makeFakeComment({ 28 | id: undefined, 29 | text: 'Something safe and intelligible.' 30 | }) 31 | ) 32 | expect(response.status).toBe(201) 33 | const { posted } = response.data 34 | const doc = await commentsDb.findById(posted) 35 | expect(doc).toEqual(posted) 36 | expect(doc.published).toBe(true) 37 | return commentsDb.remove(posted) 38 | }) 39 | it('requires comment to contain an author', async () => { 40 | const response = await axios.post( 41 | '/comments', 42 | makeFakeComment({ id: undefined, author: undefined }) 43 | ) 44 | expect(response.status).toBe(400) 45 | expect(response.data.error).toBeDefined() 46 | }) 47 | it('requires comment to contain text', async () => { 48 | const response = await axios.post( 49 | '/comments', 50 | makeFakeComment({ id: undefined, text: undefined }) 51 | ) 52 | expect(response.status).toBe(400) 53 | expect(response.data.error).toBeDefined() 54 | }) 55 | it('requires comment to contain a valid postId', async () => { 56 | const response = await axios.post( 57 | '/comments', 58 | makeFakeComment({ id: undefined, postId: undefined }) 59 | ) 60 | expect(response.status).toBe(400) 61 | expect(response.data.error).toBeDefined() 62 | }) 63 | it('scrubs malicious content', async () => { 64 | const response = await axios.post( 65 | '/comments', 66 | makeFakeComment({ 67 | id: undefined, 68 | text: '

hello!

' 69 | }) 70 | ) 71 | expect(response.status).toBe(201) 72 | expect(response.data.posted.text).toBe('

hello!

') 73 | return commentsDb.remove(response.data.posted) 74 | }) 75 | it("won't publish profanity", async () => { 76 | const profane = makeFakeComment({ id: undefined, text: 'You suck!' }) 77 | const response = await axios.post('/comments', profane) 78 | expect(response.status).toBe(201) 79 | expect(response.data.posted.published).toBe(false) 80 | return commentsDb.remove(response.data.posted) 81 | }) 82 | it.todo("won't publish spam") 83 | }) 84 | describe('modfying comments', () => { 85 | // Content moderator API only allows 1 request per second. 86 | beforeEach(done => setTimeout(() => done(), 1100)) 87 | it('modifies a comment', async () => { 88 | const comment = makeFakeComment({ 89 | text: '

changed!

' 90 | }) 91 | await commentsDb.insert(comment) 92 | const response = await axios.patch(`/comments/${comment.id}`, comment) 93 | expect(response.status).toBe(200) 94 | expect(response.data.patched.text).toBe('

changed!

') 95 | return commentsDb.remove(comment) 96 | }) 97 | it('scrubs malicious content', async () => { 98 | const comment = makeFakeComment({ 99 | text: '

hello!

' 100 | }) 101 | await commentsDb.insert(comment) 102 | const response = await axios.patch(`/comments/${comment.id}`, comment) 103 | expect(response.status).toBe(200) 104 | expect(response.data.patched.text).toBe('

hello!

') 105 | return commentsDb.remove(comment) 106 | }) 107 | }) 108 | describe('listing comments', () => { 109 | it('lists comments for a post', async () => { 110 | const comment1 = makeFakeComment({ replyToId: null }) 111 | const comment2 = makeFakeComment({ 112 | postId: comment1.postId, 113 | replyToId: null 114 | }) 115 | const comments = [comment1, comment2] 116 | const inserts = await Promise.all(comments.map(commentsDb.insert)) 117 | const expected = [ 118 | { 119 | ...comment1, 120 | replies: [], 121 | createdOn: inserts[0].createdOn 122 | }, 123 | { 124 | ...comment2, 125 | replies: [], 126 | createdOn: inserts[1].createdOn 127 | } 128 | ] 129 | const response = await axios.get('/comments/', { 130 | params: { postId: comment1.postId } 131 | }) 132 | expect(response.data).toContainEqual(expected[0]) 133 | expect(response.data).toContainEqual(expected[1]) 134 | return comments.map(commentsDb.remove) 135 | }) 136 | it('threads comments', async done => { 137 | const comment1 = makeFakeComment({ replyToId: null }) 138 | const reply1 = makeFakeComment({ 139 | postId: comment1.postId, 140 | replyToId: comment1.id 141 | }) 142 | const reply2 = makeFakeComment({ 143 | postId: comment1.postId, 144 | replyToId: reply1.id 145 | }) 146 | const comment2 = makeFakeComment({ 147 | postId: comment1.postId, 148 | replyToId: null 149 | }) 150 | const comments = [comment1, reply1, reply2, comment2] 151 | const inserts = await Promise.all(comments.map(commentsDb.insert)) 152 | const expected = [ 153 | { 154 | ...comment1, 155 | replies: [ 156 | { 157 | ...reply1, 158 | createdOn: inserts[1].createdOn, 159 | replies: [ 160 | { 161 | ...reply2, 162 | createdOn: inserts[2].createdOn, 163 | replies: [] 164 | } 165 | ] 166 | } 167 | ], 168 | createdOn: inserts[0].createdOn 169 | }, 170 | { 171 | ...comment2, 172 | replies: [], 173 | createdOn: inserts[3].createdOn 174 | } 175 | ] 176 | const response = await axios.get('/comments/', { 177 | params: { postId: comment1.postId } 178 | }) 179 | // FIXME: Fix flake. Why timeout? Mongo or promise? 180 | setTimeout(async () => { 181 | expect(response.data[0].replies.length).toBe(1) 182 | expect(response.data[0].replies[0].replies.length).toBe(1) 183 | expect(response.data).toContainEqual(expected[1]) 184 | expect(response.data).toContainEqual(expected[0]) 185 | done() 186 | }, 1100) 187 | }) 188 | }) 189 | describe('deleting comments', () => { 190 | it('hard deletes', async () => { 191 | const comment = makeFakeComment() 192 | await commentsDb.insert(comment) 193 | const result = await axios.delete(`/comments/${comment.id}`) 194 | expect(result.data.deleted.deletedCount).toBe(1) 195 | expect(result.data.deleted.softDelete).toBe(false) 196 | }) 197 | it('soft deletes', async () => { 198 | const comment = makeFakeComment() 199 | const reply = makeFakeComment({ replyToId: comment.id }) 200 | await commentsDb.insert(comment) 201 | await commentsDb.insert(reply) 202 | const result = await axios.delete(`/comments/${comment.id}`) 203 | expect(result.data.deleted.deletedCount).toBe(1) 204 | expect(result.data.deleted.softDelete).toBe(true) 205 | }) 206 | }) 207 | }) 208 | -------------------------------------------------------------------------------- /__test__/fixtures/comment.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import cuid from 'cuid' 3 | import crypto from 'crypto' 4 | 5 | const Id = Object.freeze({ 6 | makeId: cuid, 7 | isValidId: cuid.isCuid 8 | }) 9 | 10 | function md5 (text) { 11 | return crypto 12 | .createHash('md5') 13 | .update(text, 'utf-8') 14 | .digest('hex') 15 | } 16 | 17 | export default function makeFakeComment (overrides) { 18 | const comment = { 19 | author: faker.name.findName(), 20 | createdOn: Date.now(), 21 | id: Id.makeId(), 22 | modifiedOn: Date.now(), 23 | postId: Id.makeId(), 24 | published: true, 25 | replyToId: Id.makeId(), 26 | text: faker.lorem.paragraph(3), 27 | source: { 28 | ip: faker.internet.ip(), 29 | browser: faker.internet.userAgent(), 30 | referrer: faker.internet.url() 31 | } 32 | } 33 | comment.hash = md5( 34 | comment.text + 35 | comment.published + 36 | (comment.author || '') + 37 | (comment.postId || '') + 38 | (comment.replyToId || '') 39 | ) 40 | 41 | return { 42 | ...comment, 43 | ...overrides 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /__test__/fixtures/db.js: -------------------------------------------------------------------------------- 1 | import mongodb from 'mongodb' 2 | const MongoClient = mongodb.MongoClient 3 | 4 | let connection, db 5 | 6 | export default async function makeDb () { 7 | connection = 8 | connection || 9 | (await MongoClient.connect( 10 | global.__MONGO_URI__, 11 | { useNewUrlParser: true } 12 | )) 13 | db = db || (await connection.db(global.__MONGO_DB_NAME__)) 14 | return db 15 | } 16 | 17 | export async function closeDb () { 18 | await connection.close() 19 | await db.close() 20 | } 21 | 22 | export async function clearDb () { 23 | await db.collection('comments').deleteMany({}) 24 | return true 25 | } 26 | 27 | export { connection, db } 28 | -------------------------------------------------------------------------------- /__test__/fixtures/moderation-api/no-classification.json: -------------------------------------------------------------------------------- 1 | { 2 | "OriginalText": "¿Es este un correo electrónico abcdef@abcd.com, teléfono: 6657789887, IP: 255.255.255.255, 1 Microsoft Way, Redmond, WA 98052", 3 | "NormalizedText": "¿ Es este un correo electrónico abcdef@ abcd. com, teléfono: 6657789887, IP: 255. 255. 255. 255, 1 Microsoft Way, Redmond, WA 98052", 4 | "Misrepresentation": null, 5 | "Language": "spa", 6 | "Terms": null, 7 | "Status": { 8 | "Code": 3000, 9 | "Description": "OK", 10 | "Exception": null 11 | }, 12 | "TrackingId": "3c8260c0-bab0-43a9-a1aa-ae82bd2bcd45" 13 | } -------------------------------------------------------------------------------- /__test__/fixtures/moderation-api/no-review.json: -------------------------------------------------------------------------------- 1 | { 2 | "OriginalText": "

A lovely sentence with no objectionable content.

", 3 | "NormalizedText": "A lovely sentence with no objectionable content.", 4 | "Misrepresentation": null, 5 | "Classification": { 6 | "ReviewRecommended": false, 7 | "Category1": { 8 | "Score": 0.0043876972049474716 9 | }, 10 | "Category2": { 11 | "Score": 0.22932250797748566 12 | }, 13 | "Category3": { 14 | "Score": 0.06878092885017395 15 | } 16 | }, 17 | "Language": "eng", 18 | "Terms": null, 19 | "Status": { 20 | "Code": 3000, 21 | "Description": "OK", 22 | "Exception": null 23 | }, 24 | "TrackingId": "5e7a2c7f-0467-492c-8ea0-ae58124978d0" 25 | } -------------------------------------------------------------------------------- /__test__/fixtures/moderation-api/review.json: -------------------------------------------------------------------------------- 1 | { 2 | "OriginalText": "Is this a crap email abcdef@abcd.com, phone: 6657789887, IP: 255.255.255.255, 1 Microsoft Way, Redmond, WA 98052", 3 | "NormalizedText": "Is this a crap email abcdef@ abcd. com, phone: 6657789887, IP: 255. 255. 255. 255, 1 Microsoft Way, Redmond, WA 98052", 4 | "Misrepresentation": null, 5 | "Classification": { 6 | "ReviewRecommended": true, 7 | "Category1": { 8 | "Score": 0.00040505084325559437 9 | }, 10 | "Category2": { 11 | "Score": 0.22345089912414551 12 | }, 13 | "Category3": { 14 | "Score": 0.98799997568130493 15 | } 16 | }, 17 | "Language": "eng", 18 | "Terms": [ 19 | { 20 | "Index": 10, 21 | "OriginalIndex": 10, 22 | "ListId": 0, 23 | "Term": "crap" 24 | } 25 | ], 26 | "Status": { 27 | "Code": 3000, 28 | "Description": "OK", 29 | "Exception": null 30 | }, 31 | "TrackingId": "9af0d5b1-d68a-4b50-ace1-1f5a530ff682" 32 | } -------------------------------------------------------------------------------- /__test__/is-questionable.spec.js: -------------------------------------------------------------------------------- 1 | import isQuestionable from '../src/is-questionable' 2 | import review from './fixtures/moderation-api/review.json' 3 | import noReview from './fixtures/moderation-api/no-review.json' 4 | import noClassification from './fixtures/moderation-api/no-classification.json' 5 | import makeFakeComment from './fixtures/comment' 6 | 7 | describe('Is Questionable', () => { 8 | // Content moderator API only allows 1 request per second. 9 | afterEach(done => setTimeout(() => done(), 1100)) 10 | it('flags inappropriate content', async () => { 11 | const comment = makeFakeComment({ text: review.OriginalText }) 12 | const result = await isQuestionable({ 13 | text: comment.text, 14 | ip: comment.source.ip, 15 | browser: comment.source.browser, 16 | referrer: comment.source.referrer, 17 | author: comment.author, 18 | createdOn: comment.createdOn, 19 | modifiedOn: comment.modifiedOn, 20 | testOnly: true 21 | }) 22 | expect(result).toBe(true) 23 | }) 24 | it('flags unintelligible content', async () => { 25 | const comment = makeFakeComment({ text: noClassification.OriginalText }) 26 | const result = await isQuestionable({ 27 | text: comment.text, 28 | ip: comment.source.ip, 29 | browser: comment.source.browser, 30 | referrer: comment.source.referrer, 31 | author: comment.author, 32 | createdOn: comment.createdOn, 33 | modifiedOn: comment.modifiedOn, 34 | testOnly: true 35 | }) 36 | expect(result).toBe(true) 37 | }) 38 | it('accepts appropriate content', async () => { 39 | const comment = makeFakeComment({ text: noReview.OriginalText }) 40 | const result = await isQuestionable({ 41 | text: comment.text, 42 | ip: comment.source.ip, 43 | browser: comment.source.browser, 44 | referrer: comment.source.referrer, 45 | author: comment.author, 46 | createdOn: comment.createdOn, 47 | modifiedOn: comment.modifiedOn, 48 | testOnly: true 49 | }) 50 | expect(result).toBe(false) 51 | }) 52 | it('filters out spam', async () => { 53 | const comment = makeFakeComment({ 54 | text: noReview.OriginalText, 55 | author: 'viagra-test-123' 56 | }) 57 | const result = await isQuestionable({ 58 | text: comment.text, 59 | ip: comment.source.ip, 60 | browser: comment.source.browser, 61 | referrer: comment.source.referrer, 62 | author: comment.author, 63 | createdOn: comment.createdOn, 64 | modifiedOn: comment.modifiedOn, 65 | testOnly: true 66 | }) 67 | expect(result).toBe(true) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /db/index.js: -------------------------------------------------------------------------------- 1 | import { makeDb } from '../src/data-access' 2 | import dotenv from 'dotenv' 3 | dotenv.config() 4 | ;(async function setupDb () { 5 | console.log('Setting up database...') 6 | // database collection will automatically be created if it does not exist 7 | // indexes will only be added if they don't exist 8 | const db = await makeDb() 9 | const result = await db 10 | .collection('comments') 11 | .createIndexes([ 12 | { key: { hash: 1 }, name: 'hash_idx' }, 13 | { key: { postId: -1 }, name: 'postId_idx' }, 14 | { key: { replyToId: -1 }, name: 'replyToId_idx' } 15 | ]) 16 | console.log(result) 17 | console.log('Database setup complete...') 18 | process.exit() 19 | })() 20 | -------------------------------------------------------------------------------- /jest-mongo.js: -------------------------------------------------------------------------------- 1 | const NodeEnvironment = require('jest-environment-node') 2 | 3 | const path = require('path') 4 | 5 | const fs = require('fs') 6 | 7 | const globalConfigPath = path.join(__dirname, 'globalConfigMongo.json') 8 | 9 | class MongoEnvironment extends NodeEnvironment { 10 | constructor (config) { 11 | super(config) 12 | } 13 | 14 | async setup () { 15 | const globalConfig = JSON.parse(fs.readFileSync(globalConfigPath, 'utf-8')) 16 | 17 | this.global.__MONGO_URI__ = globalConfig.mongoUri 18 | this.global.__MONGO_DB_NAME__ = globalConfig.mongoDBName 19 | 20 | await super.setup() 21 | } 22 | 23 | async teardown () { 24 | await super.teardown() 25 | } 26 | 27 | runScript (script) { 28 | return super.runScript(script) 29 | } 30 | } 31 | 32 | module.exports = MongoEnvironment 33 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const fs = require('fs') 4 | 5 | const { MongoMemoryServer } = require('mongodb-memory-server') 6 | 7 | const globalConfigPath = path.join(__dirname, 'globalConfigMongo.json') 8 | 9 | const mongod = 10 | global.__MONGOD__ || 11 | new MongoMemoryServer({ 12 | autoStart: false 13 | }) 14 | 15 | module.exports = async () => { 16 | if (!mongod.runningInstance) { 17 | await mongod.start() 18 | } 19 | 20 | const mongoConfig = { 21 | mongoDBName: 'jest', 22 | mongoUri: await mongod.getConnectionString() 23 | } 24 | 25 | // Write global config to disk because all tests run in different contexts. 26 | fs.writeFileSync(globalConfigPath, JSON.stringify(mongoConfig)) 27 | 28 | // Set reference to mongod in order to close the server during teardown. 29 | global.__MONGOD__ = mongod 30 | } 31 | -------------------------------------------------------------------------------- /jest-teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = async function ({ watch, watchAll } = {}) { 2 | if (!watch && !watchAll) { 3 | await global.__MONGOD__.stop() 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comments-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npm run clean && babel src -d dist", 8 | "now-build": "npm run build", 9 | "clean": "rimraf dist", 10 | "db": "babel-node ./db/index.js", 11 | "dev": "nodemon --exec babel-node ./src/index.js", 12 | "start": "npm run clean && npm run build && npm run db && cd dist && node index.js", 13 | "test": "jest src --watch", 14 | "test:e2e": "jest ./__test__ --runInBand" 15 | }, 16 | "author": "Bill Sourour ", 17 | "license": "MIT", 18 | "jest": { 19 | "verbose": false, 20 | "globalSetup": "./jest-setup.js", 21 | "globalTeardown": "./jest-teardown.js", 22 | "testEnvironment": "./jest-mongo.js" 23 | }, 24 | "dependencies": { 25 | "@devmastery/pipe": "^0.0.6", 26 | "axios": "^0.18.0", 27 | "babel-plugin-inline-dotenv": "^1.2.2", 28 | "body-parser": "^1.18.3", 29 | "cuid": "^2.1.6", 30 | "express": "^4.16.4", 31 | "ip-regex": "^4.0.0", 32 | "is-valid-email": "0.0.4", 33 | "mongodb": "^3.1.13", 34 | "p-reduce": "^2.0.0", 35 | "sanitize-html": "^1.20.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/cli": "^7.2.3", 39 | "@babel/core": "^7.3.4", 40 | "@babel/node": "^7.2.2", 41 | "@babel/preset-env": "^7.3.4", 42 | "babel-jest": "^24.4.0", 43 | "dotenv": "^6.2.0", 44 | "eslint": "^5.15.1", 45 | "eslint-config-standard": "^12.0.0", 46 | "eslint-plugin-import": "^2.16.0", 47 | "eslint-plugin-node": "^8.0.1", 48 | "eslint-plugin-promise": "^4.0.1", 49 | "eslint-plugin-standard": "^4.0.0", 50 | "faker": "^4.1.0", 51 | "jest": "^24.3.1", 52 | "mongodb-memory-server": "^4.0.2", 53 | "nodemon": "^1.18.10", 54 | "rimraf": "^2.6.3" 55 | } 56 | } -------------------------------------------------------------------------------- /sampledotenv: -------------------------------------------------------------------------------- 1 | # Save this file as .env and fill in the appropriate values 2 | DM_API_ROOT=/api 3 | DM_BASE_URL=http://localhost:3000/ 4 | DM_COMMENTS_DB_URL=mongodb://localhost:27017 5 | DM_COMMENTS_DB_NAME=dm_comments_api 6 | DM_MODERATOR_API_URL=https://.api.cognitive.microsoft.com/contentmoderator/moderate/v1.0/ProcessText/Screen 7 | 8 | # Signup for a free Moderator key at: 9 | # https://contentmoderator.cognitive.microsoft.com 10 | DM_MODERATOR_API_KEY=xxxxx 11 | 12 | # Signup for a free Akismet Spam API key at: 13 | # https://akismet.com/development/api/#getting-started 14 | DM_SPAM_API_URL=https://xxxxxxx.rest.akismet.com/1.1/comment-check -------------------------------------------------------------------------------- /src/Id/index.js: -------------------------------------------------------------------------------- 1 | import cuid from 'cuid' 2 | 3 | const Id = Object.freeze({ 4 | makeId: cuid, 5 | isValidId: cuid.isCuid 6 | }) 7 | 8 | export default Id 9 | -------------------------------------------------------------------------------- /src/comment/comment.js: -------------------------------------------------------------------------------- 1 | export default function buildMakeComment ({ Id, md5, sanitize, makeSource }) { 2 | return function makeComment ({ 3 | author, 4 | createdOn = Date.now(), 5 | id = Id.makeId(), 6 | source, 7 | modifiedOn = Date.now(), 8 | postId, 9 | published = false, 10 | replyToId, 11 | text 12 | } = {}) { 13 | if (!Id.isValidId(id)) { 14 | throw new Error('Comment must have a valid id.') 15 | } 16 | if (!author) { 17 | throw new Error('Comment must have an author.') 18 | } 19 | if (author.length < 2) { 20 | throw new Error("Comment author's name must be longer than 2 characters.") 21 | } 22 | if (!postId) { 23 | throw new Error('Comment must contain a postId.') 24 | } 25 | if (!text || text.length < 1) { 26 | throw new Error('Comment must include at least one character of text.') 27 | } 28 | if (!source) { 29 | throw new Error('Comment must have a source.') 30 | } 31 | if (replyToId && !Id.isValidId(replyToId)) { 32 | throw new Error('If supplied. Comment must contain a valid replyToId.') 33 | } 34 | 35 | let sanitizedText = sanitize(text).trim() 36 | if (sanitizedText.length < 1) { 37 | throw new Error('Comment contains no usable text.') 38 | } 39 | 40 | const validSource = makeSource(source) 41 | const deletedText = '.xX This comment has been deleted Xx.' 42 | let hash 43 | 44 | return Object.freeze({ 45 | getAuthor: () => author, 46 | getCreatedOn: () => createdOn, 47 | getHash: () => hash || (hash = makeHash()), 48 | getId: () => id, 49 | getModifiedOn: () => modifiedOn, 50 | getPostId: () => postId, 51 | getReplyToId: () => replyToId, 52 | getSource: () => validSource, 53 | getText: () => sanitizedText, 54 | isDeleted: () => sanitizedText === deletedText, 55 | isPublished: () => published, 56 | markDeleted: () => { 57 | sanitizedText = deletedText 58 | author = 'deleted' 59 | }, 60 | publish: () => { 61 | published = true 62 | }, 63 | unPublish: () => { 64 | published = false 65 | } 66 | }) 67 | 68 | function makeHash () { 69 | return md5( 70 | sanitizedText + 71 | published + 72 | (author || '') + 73 | (postId || '') + 74 | (replyToId || '') 75 | ) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/comment/comment.spec.js: -------------------------------------------------------------------------------- 1 | import makeFakeComment from '../../__test__/fixtures/comment' 2 | import makeComment from './' 3 | describe('comment', () => { 4 | it('must have an author', () => { 5 | const comment = makeFakeComment({ author: null }) 6 | expect(() => makeComment(comment)).toThrow('Comment must have an author.') 7 | }) 8 | 9 | it('must have a valid post id', () => { 10 | const comment = makeFakeComment({ postId: null }) 11 | expect(() => makeComment(comment)).toThrow('Comment must contain a postId.') 12 | }) 13 | it('must have valid text', () => { 14 | const comment = makeFakeComment({ text: null }) 15 | expect(() => makeComment(comment)).toThrow( 16 | 'Comment must include at least one character of text.' 17 | ) 18 | }) 19 | it('can be in reply to another comment', () => { 20 | const comment = makeFakeComment({ replyToId: 'invalid' }) 21 | expect(() => makeComment(comment)).toThrow( 22 | 'If supplied. Comment must contain a valid replyToId.' 23 | ) 24 | const notInReply = makeFakeComment({ replyToId: undefined }) 25 | expect(() => makeComment(notInReply)).not.toThrow() 26 | }) 27 | it('can have an id', () => { 28 | const comment = makeFakeComment({ id: 'invalid' }) 29 | expect(() => makeComment(comment)).toThrow('Comment must have a valid id.') 30 | const noId = makeFakeComment({ id: undefined }) 31 | expect(() => makeComment(noId)).not.toThrow() 32 | }) 33 | it('can create an id', () => { 34 | const noId = makeFakeComment({ id: undefined }) 35 | const comment = makeComment(noId) 36 | expect(comment.getId()).toBeDefined() 37 | }) 38 | it('can be published', () => { 39 | const unpublished = makeFakeComment({ published: false }) 40 | const comment = makeComment(unpublished) 41 | expect(comment.isPublished()).toBe(false) 42 | comment.publish() 43 | expect(comment.isPublished()).toBe(true) 44 | }) 45 | it('can be unpublished', () => { 46 | const unpublished = makeFakeComment({ published: true }) 47 | const comment = makeComment(unpublished) 48 | expect(comment.isPublished()).toBe(true) 49 | comment.unPublish() 50 | expect(comment.isPublished()).toBe(false) 51 | }) 52 | it('is createdOn now in UTC', () => { 53 | const noCreationDate = makeFakeComment({ createdOn: undefined }) 54 | expect(noCreationDate.createdOn).not.toBeDefined() 55 | const d = makeComment(noCreationDate).getCreatedOn() 56 | expect(d).toBeDefined() 57 | expect(new Date(d).toUTCString().substring(26)).toBe('GMT') 58 | }) 59 | it('is modifiedOn now in UTC', () => { 60 | const noModifiedOnDate = makeFakeComment({ modifiedOn: undefined }) 61 | expect(noModifiedOnDate.modifiedOn).not.toBeDefined() 62 | const d = makeComment(noModifiedOnDate).getCreatedOn() 63 | expect(d).toBeDefined() 64 | expect(new Date(d).toUTCString().substring(26)).toBe('GMT') 65 | }) 66 | it('sanitizes its text', () => { 67 | const sane = makeComment({ 68 | ...makeFakeComment({ text: '

This is fine

' }) 69 | }) 70 | const insane = makeComment({ 71 | ...makeFakeComment({ 72 | text: '

but this is ok

' 73 | }) 74 | }) 75 | const totallyInsane = makeFakeComment({ 76 | text: '' 77 | }) 78 | 79 | expect(sane.getText()).toBe('

This is fine

') 80 | expect(insane.getText()).toBe('

but this is ok

') 81 | expect(() => makeComment(totallyInsane)).toThrow( 82 | 'Comment contains no usable text.' 83 | ) 84 | }) 85 | it('can be marked deleted', () => { 86 | const fake = makeFakeComment() 87 | const c = makeComment(fake) 88 | c.markDeleted() 89 | expect(c.isDeleted()).toBe(true) 90 | expect(c.getText()).toBe('.xX This comment has been deleted Xx.') 91 | expect(c.getAuthor()).toBe('deleted') 92 | }) 93 | it('includes a hash', () => { 94 | const fakeComment = { 95 | author: 'Bruce Wayne', 96 | text: "I'm batman.", 97 | postId: 'cjt65art5350vy000hm1rp3s9', 98 | published: true, 99 | source: { ip: '127.0.0.1' } 100 | } 101 | // md5 from: http://www.miraclesalad.com/webtools/md5.php 102 | expect(makeComment(fakeComment).getHash()).toBe( 103 | '7bb94f070d9305976b5381b7d3e8ad8a' 104 | ) 105 | }) 106 | it('must have a source', () => { 107 | const noSource = makeFakeComment({ source: undefined }) 108 | expect(() => makeComment(noSource)).toThrow('Comment must have a source.') 109 | }) 110 | it('must have a source ip', () => { 111 | const noIp = makeFakeComment({ source: { ip: undefined } }) 112 | expect(() => makeComment(noIp)).toThrow( 113 | 'Comment source must contain an IP.' 114 | ) 115 | }) 116 | it('can have a source browser', () => { 117 | const withBrowser = makeFakeComment() 118 | expect( 119 | makeComment(withBrowser) 120 | .getSource() 121 | .getBrowser() 122 | ).toBe(withBrowser.source.browser) 123 | }) 124 | it('can have a source referrer', () => { 125 | const withRef = makeFakeComment() 126 | expect( 127 | makeComment(withRef) 128 | .getSource() 129 | .getReferrer() 130 | ).toBe(withRef.source.referrer) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /src/comment/index.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import Id from '../Id' 3 | import ipRegex from 'ip-regex' 4 | import sanitizeHtml from 'sanitize-html' 5 | import buildMakeComment from './comment' 6 | import buildMakeSource from './source' 7 | 8 | const makeSource = buildMakeSource({ isValidIp }) 9 | const makeComment = buildMakeComment({ Id, md5, sanitize, makeSource }) 10 | 11 | export default makeComment 12 | 13 | function isValidIp (ip) { 14 | return ipRegex({ exact: true }).test(ip) 15 | } 16 | 17 | function md5 (text) { 18 | return crypto 19 | .createHash('md5') 20 | .update(text, 'utf-8') 21 | .digest('hex') 22 | } 23 | 24 | function sanitize (text) { 25 | // TODO: allow more coding embeds 26 | return sanitizeHtml(text, { 27 | allowedIframeHostnames: ['codesandbox.io', 'repl.it'] 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/comment/source.js: -------------------------------------------------------------------------------- 1 | export default function buildMakeSource ({ isValidIp }) { 2 | return function makeSource ({ ip, browser, referrer } = {}) { 3 | if (!ip) { 4 | throw new Error('Comment source must contain an IP.') 5 | } 6 | if (!isValidIp(ip)) { 7 | throw new RangeError('Comment source must contain a valid IP.') 8 | } 9 | return Object.freeze({ 10 | getIp: () => ip, 11 | getBrowser: () => browser, 12 | getReferrer: () => referrer 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/controllers/delete-comment.js: -------------------------------------------------------------------------------- 1 | export default function makeDeleteComment ({ removeComment }) { 2 | return async function deleteComment (httpRequest) { 3 | const headers = { 4 | 'Content-Type': 'application/json' 5 | } 6 | try { 7 | const deleted = await removeComment({ id: httpRequest.params.id }) 8 | return { 9 | headers, 10 | statusCode: deleted.deletedCount === 0 ? 404 : 200, 11 | body: { deleted } 12 | } 13 | } catch (e) { 14 | // TODO: Error logging 15 | console.log(e) 16 | return { 17 | headers, 18 | statusCode: 400, 19 | body: { 20 | error: e.message 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/controllers/get-comments.js: -------------------------------------------------------------------------------- 1 | export default function makeGetComments ({ listComments }) { 2 | return async function getComments (httpRequest) { 3 | const headers = { 4 | 'Content-Type': 'application/json' 5 | } 6 | try { 7 | const postComments = await listComments({ 8 | postId: httpRequest.query.postId 9 | }) 10 | return { 11 | headers, 12 | statusCode: 200, 13 | body: postComments 14 | } 15 | } catch (e) { 16 | // TODO: Error logging 17 | console.log(e) 18 | return { 19 | headers, 20 | statusCode: 400, 21 | body: { 22 | error: e.message 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/controllers/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | addComment, 3 | editComment, 4 | listComments, 5 | removeComment 6 | } from '../use-cases' 7 | import makeDeleteComment from './delete-comment' 8 | import makeGetComments from './get-comments' 9 | import makePostComment from './post-comment' 10 | import makePatchComment from './patch-comment' 11 | import notFound from './not-found' 12 | 13 | const deleteComment = makeDeleteComment({ removeComment }) 14 | const getComments = makeGetComments({ 15 | listComments 16 | }) 17 | const postComment = makePostComment({ addComment }) 18 | const patchComment = makePatchComment({ editComment }) 19 | 20 | const commentController = Object.freeze({ 21 | deleteComment, 22 | getComments, 23 | notFound, 24 | postComment, 25 | patchComment 26 | }) 27 | 28 | export default commentController 29 | export { deleteComment, getComments, notFound, postComment, patchComment } 30 | -------------------------------------------------------------------------------- /src/controllers/not-found.js: -------------------------------------------------------------------------------- 1 | export default async function notFound () { 2 | return { 3 | headers: { 4 | 'Content-Type': 'application/json' 5 | }, 6 | body: { error: 'Not found.' }, 7 | statusCode: 404 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/controllers/patch-comment.js: -------------------------------------------------------------------------------- 1 | export default function makePatchComment ({ editComment }) { 2 | return async function patchComment (httpRequest) { 3 | try { 4 | const { source = {}, ...commentInfo } = httpRequest.body 5 | source.ip = httpRequest.ip 6 | source.browser = httpRequest.headers['User-Agent'] 7 | if (httpRequest.headers['Referer']) { 8 | source.referrer = httpRequest.headers['Referer'] 9 | } 10 | const toEdit = { 11 | ...commentInfo, 12 | source, 13 | id: httpRequest.params.id 14 | } 15 | const patched = await editComment(toEdit) 16 | return { 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | 'Last-Modified': new Date(patched.modifiedOn).toUTCString() 20 | }, 21 | statusCode: 200, 22 | body: { patched } 23 | } 24 | } catch (e) { 25 | // TODO: Error logging 26 | console.log(e) 27 | if (e.name === 'RangeError') { 28 | return { 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | }, 32 | statusCode: 404, 33 | body: { 34 | error: e.message 35 | } 36 | } 37 | } 38 | return { 39 | headers: { 40 | 'Content-Type': 'application/json' 41 | }, 42 | statusCode: 400, 43 | body: { 44 | error: e.message 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/controllers/patch-comment.spec.js: -------------------------------------------------------------------------------- 1 | import makePatchComment from './patch-comment' 2 | import makeFakeComment from '../../__test__/fixtures/comment' 3 | 4 | describe('patch comment controller', () => { 5 | it('successfully patches a comment', async () => { 6 | const fakeComment = makeFakeComment() 7 | const patchComment = makePatchComment({ editComment: c => c }) 8 | const request = { 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | Referer: fakeComment.source.referrer, 12 | 'User-Agent': fakeComment.source.browser 13 | }, 14 | params: { 15 | id: fakeComment.id 16 | }, 17 | body: fakeComment 18 | } 19 | const expected = { 20 | headers: { 21 | 'Content-Type': 'application/json', 22 | 'Last-Modified': new Date(fakeComment.modifiedOn).toUTCString() 23 | }, 24 | statusCode: 200, 25 | body: { patched: request.body } 26 | } 27 | const actual = await patchComment(request) 28 | expect(actual).toEqual(expected) 29 | }) 30 | it('reports user errors', async () => { 31 | const fakeComment = makeFakeComment() 32 | const patchComment = makePatchComment({ 33 | editComment: () => { 34 | throw Error('Pow!') 35 | } 36 | }) 37 | const request = { 38 | headers: { 39 | 'Content-Type': 'application/json', 40 | Referer: fakeComment.source.referrer, 41 | 'User-Agent': fakeComment.source.browser 42 | }, 43 | params: { 44 | id: fakeComment.id 45 | }, 46 | body: fakeComment 47 | } 48 | const expected = { 49 | headers: { 50 | 'Content-Type': 'application/json' 51 | }, 52 | statusCode: 400, 53 | body: { error: 'Pow!' } 54 | } 55 | const actual = await patchComment(request) 56 | expect(actual).toEqual(expected) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /src/controllers/post-comment.js: -------------------------------------------------------------------------------- 1 | export default function makePostComment ({ addComment }) { 2 | return async function postComment (httpRequest) { 3 | try { 4 | const { source = {}, ...commentInfo } = httpRequest.body 5 | source.ip = httpRequest.ip 6 | source.browser = httpRequest.headers['User-Agent'] 7 | if (httpRequest.headers['Referer']) { 8 | source.referrer = httpRequest.headers['Referer'] 9 | } 10 | const posted = await addComment({ 11 | ...commentInfo, 12 | source 13 | }) 14 | return { 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | 'Last-Modified': new Date(posted.modifiedOn).toUTCString() 18 | }, 19 | statusCode: 201, 20 | body: { posted } 21 | } 22 | } catch (e) { 23 | // TODO: Error logging 24 | console.log(e) 25 | 26 | return { 27 | headers: { 28 | 'Content-Type': 'application/json' 29 | }, 30 | statusCode: 400, 31 | body: { 32 | error: e.message 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/controllers/post-comment.spec.js: -------------------------------------------------------------------------------- 1 | import makePostComment from './post-comment' 2 | import makeFakeComment from '../../__test__/fixtures/comment' 3 | 4 | describe('post comment controller', () => { 5 | it('successfully posts a comment', async () => { 6 | const postComment = makePostComment({ addComment: c => c }) 7 | const comment = makeFakeComment() 8 | const request = { 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | Referer: comment.source.referrer, 12 | 'User-Agent': comment.source.browser 13 | }, 14 | body: comment, 15 | ip: comment.source.ip 16 | } 17 | const expected = { 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | 'Last-Modified': new Date(request.body.modifiedOn).toUTCString() 21 | }, 22 | statusCode: 201, 23 | body: { posted: request.body } 24 | } 25 | const actual = await postComment(request) 26 | expect(actual).toEqual(expected) 27 | }) 28 | it('reports user errors', async () => { 29 | const postComment = makePostComment({ 30 | addComment: () => { 31 | throw Error('Pow!') 32 | } 33 | }) 34 | const fakeComment = makeFakeComment() 35 | const request = { 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | Referer: fakeComment.source.referrer, 39 | 'User-Agent': fakeComment.source.browser 40 | }, 41 | body: fakeComment 42 | } 43 | const expected = { 44 | headers: { 45 | 'Content-Type': 'application/json' 46 | }, 47 | statusCode: 400, 48 | body: { error: 'Pow!' } 49 | } 50 | const actual = await postComment(request) 51 | expect(actual).toEqual(expected) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/data-access/comments-db.js: -------------------------------------------------------------------------------- 1 | import Id from '../Id' 2 | 3 | export default function makeCommentsDb ({ makeDb }) { 4 | return Object.freeze({ 5 | findAll, 6 | findByHash, 7 | findById, 8 | findByPostId, 9 | findReplies, 10 | insert, 11 | remove, 12 | update 13 | }) 14 | async function findAll ({ publishedOnly = true } = {}) { 15 | const db = await makeDb() 16 | const query = publishedOnly ? { published: true } : {} 17 | const result = await db.collection('comments').find(query) 18 | return (await result.toArray()).map(({ _id: id, ...found }) => ({ 19 | id, 20 | ...found 21 | })) 22 | } 23 | async function findById ({ id: _id }) { 24 | const db = await makeDb() 25 | const result = await db.collection('comments').find({ _id }) 26 | const found = await result.toArray() 27 | if (found.length === 0) { 28 | return null 29 | } 30 | const { _id: id, ...info } = found[0] 31 | return { id, ...info } 32 | } 33 | async function findByPostId ({ postId, omitReplies = true }) { 34 | const db = await makeDb() 35 | const query = { postId: postId } 36 | if (omitReplies) { 37 | query.replyToId = null 38 | } 39 | const result = await db.collection('comments').find(query) 40 | return (await result.toArray()).map(({ _id: id, ...found }) => ({ 41 | id, 42 | ...found 43 | })) 44 | } 45 | async function findReplies ({ commentId, publishedOnly = true }) { 46 | const db = await makeDb() 47 | const query = publishedOnly 48 | ? { published: true, replyToId: commentId } 49 | : { replyToId: commentId } 50 | const result = await db.collection('comments').find(query) 51 | return (await result.toArray()).map(({ _id: id, ...found }) => ({ 52 | id, 53 | ...found 54 | })) 55 | } 56 | async function insert ({ id: _id = Id.makeId(), ...commentInfo }) { 57 | const db = await makeDb() 58 | const result = await db 59 | .collection('comments') 60 | .insertOne({ _id, ...commentInfo }) 61 | const { _id: id, ...insertedInfo } = result.ops[0] 62 | return { id, ...insertedInfo } 63 | } 64 | 65 | async function update ({ id: _id, ...commentInfo }) { 66 | const db = await makeDb() 67 | const result = await db 68 | .collection('comments') 69 | .updateOne({ _id }, { $set: { ...commentInfo } }) 70 | return result.modifiedCount > 0 ? { id: _id, ...commentInfo } : null 71 | } 72 | async function remove ({ id: _id }) { 73 | const db = await makeDb() 74 | const result = await db.collection('comments').deleteOne({ _id }) 75 | return result.deletedCount 76 | } 77 | async function findByHash (comment) { 78 | const db = await makeDb() 79 | const result = await db.collection('comments').find({ hash: comment.hash }) 80 | const found = await result.toArray() 81 | if (found.length === 0) { 82 | return null 83 | } 84 | const { _id: id, ...insertedInfo } = found[0] 85 | return { id, ...insertedInfo } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/data-access/comments-db.spec.js: -------------------------------------------------------------------------------- 1 | import makeDb from '../../__test__/fixtures/db' 2 | import makeCommentsDb from './comments-db' 3 | import makeFakeComment from '../../__test__/fixtures/comment' 4 | 5 | describe('comments db', () => { 6 | let commentsDb 7 | 8 | beforeEach(async () => { 9 | commentsDb = makeCommentsDb({ makeDb }) 10 | }) 11 | 12 | it('lists comments', async () => { 13 | const inserts = await Promise.all( 14 | [makeFakeComment(), makeFakeComment(), makeFakeComment()].map( 15 | commentsDb.insert 16 | ) 17 | ) 18 | const found = await commentsDb.findAll() 19 | expect.assertions(inserts.length) 20 | return inserts.forEach(insert => expect(found).toContainEqual(insert)) 21 | }) 22 | 23 | it('inserts a comment', async () => { 24 | const comment = makeFakeComment() 25 | const result = await commentsDb.insert(comment) 26 | return expect(result).toEqual(comment) 27 | }) 28 | 29 | it('finds a comment by id', async () => { 30 | const comment = makeFakeComment() 31 | await commentsDb.insert(comment) 32 | const found = await commentsDb.findById(comment) 33 | expect(found).toEqual(comment) 34 | }) 35 | 36 | it("finds a comment by it's hash", async () => { 37 | // expect.assertions(2) 38 | const fakeCommentOne = makeFakeComment() 39 | const fakeCommentTwo = makeFakeComment() 40 | const insertedOne = await commentsDb.insert(fakeCommentOne) 41 | const insertedTwo = await commentsDb.insert(fakeCommentTwo) 42 | 43 | expect(await commentsDb.findByHash(fakeCommentOne)).toEqual(insertedOne) 44 | expect(await commentsDb.findByHash(fakeCommentTwo)).toEqual(insertedTwo) 45 | }) 46 | 47 | it('updates a comment', async () => { 48 | const comment = makeFakeComment() 49 | await commentsDb.insert(comment) 50 | comment.text = 'changed' 51 | const updated = await commentsDb.update(comment) 52 | return expect(updated.text).toBe('changed') 53 | }) 54 | 55 | it('finds all comments for a post', async () => { 56 | const commentOnPostA = makeFakeComment() 57 | const commentOnPostB = makeFakeComment({ replyToId: null }) 58 | await Promise.all([commentOnPostA, commentOnPostB].map(commentsDb.insert)) 59 | 60 | expect( 61 | (await commentsDb.findByPostId({ 62 | postId: commentOnPostA.postId, 63 | omitReplies: false 64 | }))[0] 65 | ).toEqual(commentOnPostA) 66 | 67 | expect( 68 | (await commentsDb.findByPostId({ 69 | postId: commentOnPostA.postId, 70 | omitReplies: true 71 | }))[0] 72 | ).not.toEqual(commentOnPostA) 73 | 74 | return expect( 75 | (await commentsDb.findByPostId({ 76 | postId: commentOnPostB.postId, 77 | omitReplies: true 78 | }))[0] 79 | ).toEqual(commentOnPostB) 80 | }) 81 | 82 | it('finds all replies to a comment', async () => { 83 | const comment = makeFakeComment() 84 | const firstReply = makeFakeComment({ replyToId: comment.id }) 85 | const secondReply = makeFakeComment({ replyToId: comment.id }) 86 | await Promise.all([comment, firstReply, secondReply].map(commentsDb.insert)) 87 | const found = await commentsDb.findReplies({ commentId: comment.id }) 88 | expect(found).toContainEqual(firstReply) 89 | expect(found).toContainEqual(secondReply) 90 | expect(found).not.toContainEqual(comment) 91 | }) 92 | 93 | it('deletes a comment', async () => { 94 | const comment = makeFakeComment() 95 | await commentsDb.insert(comment) 96 | return expect(await commentsDb.remove(comment)).toBe(1) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/data-access/index.js: -------------------------------------------------------------------------------- 1 | import makeCommentsDb from './comments-db' 2 | import mongodb from 'mongodb' 3 | 4 | const MongoClient = mongodb.MongoClient 5 | const url = process.env.DM_COMMENTS_DB_URL 6 | const dbName = process.env.DM_COMMENTS_DB_NAME 7 | const client = new MongoClient(url, { useNewUrlParser: true }) 8 | 9 | export async function makeDb () { 10 | if (!client.isConnected()) { 11 | await client.connect() 12 | } 13 | return client.db(dbName) 14 | } 15 | 16 | const commentsDb = makeCommentsDb({ makeDb }) 17 | export default commentsDb 18 | -------------------------------------------------------------------------------- /src/express-callback/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function makeExpressCallback (controller) { 2 | return (req, res) => { 3 | const httpRequest = { 4 | body: req.body, 5 | query: req.query, 6 | params: req.params, 7 | ip: req.ip, 8 | method: req.method, 9 | path: req.path, 10 | headers: { 11 | 'Content-Type': req.get('Content-Type'), 12 | Referer: req.get('referer'), 13 | 'User-Agent': req.get('User-Agent') 14 | } 15 | } 16 | controller(httpRequest) 17 | .then(httpResponse => { 18 | if (httpResponse.headers) { 19 | res.set(httpResponse.headers) 20 | } 21 | res.type('json') 22 | res.status(httpResponse.statusCode).send(httpResponse.body) 23 | }) 24 | .catch(e => res.status(500).send({ error: 'An unkown error occurred.' })) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import bodyParser from 'body-parser' 3 | import dotenv from 'dotenv' 4 | import { 5 | deleteComment, 6 | getComments, 7 | notFound, 8 | postComment, 9 | patchComment 10 | } from './controllers' 11 | import makeCallback from './express-callback' 12 | 13 | dotenv.config() 14 | 15 | const apiRoot = process.env.DM_API_ROOT 16 | const app = express() 17 | app.use(bodyParser.json()) 18 | // TODO: figure out DNT compliance. 19 | app.use((_, res, next) => { 20 | res.set({ Tk: '!' }) 21 | next() 22 | }) 23 | app.post(`${apiRoot}/comments`, makeCallback(postComment)) 24 | app.delete(`${apiRoot}/comments/:id`, makeCallback(deleteComment)) 25 | app.delete(`${apiRoot}/comments`, makeCallback(deleteComment)) 26 | app.patch(`${apiRoot}/comments/:id`, makeCallback(patchComment)) 27 | app.patch(`${apiRoot}/comments`, makeCallback(patchComment)) 28 | app.get(`${apiRoot}/comments`, makeCallback(getComments)) 29 | app.use(makeCallback(notFound)) 30 | 31 | 32 | // listen for requests 33 | app.listen(3000, () => { 34 | console.log('Server is listening on port 3000') 35 | }) 36 | 37 | 38 | export default app 39 | -------------------------------------------------------------------------------- /src/is-questionable/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import querystring from 'querystring' 3 | import pipe from '@devmastery/pipe' 4 | import makeIsQuestionable from './is-questionable' 5 | 6 | const isQuestionable = makeIsQuestionable({ 7 | issueHttpRequest: axios, 8 | pipe, 9 | querystring 10 | }) 11 | 12 | export default isQuestionable 13 | -------------------------------------------------------------------------------- /src/is-questionable/is-questionable.js: -------------------------------------------------------------------------------- 1 | export default function makeIsQuestionable ({ 2 | pipe, 3 | issueHttpRequest, 4 | querystring 5 | }) { 6 | return async function isQuestionable ({ 7 | author, 8 | browser, 9 | createdOn, 10 | ip, 11 | modifiedOn, 12 | referrer, 13 | testOnly, 14 | text 15 | } = {}) { 16 | const callModerationApi = pipe( 17 | buildModerationApiCommand, 18 | issueHttpRequest, 19 | normalizeModerationApiResponse 20 | ) 21 | const callSpamApi = pipe( 22 | buildAkismetApiCommand, 23 | issueHttpRequest, 24 | normalizeAkismetApiResponse 25 | ) 26 | 27 | try { 28 | const [inappropriate, spam] = await Promise.all([ 29 | callModerationApi(text), 30 | callSpamApi({ 31 | author, 32 | browser, 33 | createdOn, 34 | ip, 35 | modifiedOn, 36 | querystring, 37 | referrer, 38 | testOnly, 39 | text 40 | }) 41 | ]) 42 | return inappropriate || spam 43 | } catch (e) { 44 | console.log(e) 45 | return true 46 | } 47 | } 48 | } 49 | export function buildModerationApiCommand (text) { 50 | return { 51 | method: 'post', 52 | data: text, 53 | params: { classify: 'true' }, 54 | headers: { 55 | 'Content-Type': 'text/html', 56 | 'Ocp-Apim-Subscription-Key': process.env.DM_MODERATOR_API_KEY 57 | }, 58 | url: process.env.DM_MODERATOR_API_URL 59 | } 60 | } 61 | 62 | export function normalizeModerationApiResponse (response) { 63 | return ( 64 | !response.data.Classification || 65 | response.data.Classification.ReviewRecommended 66 | ) 67 | } 68 | 69 | export function buildAkismetApiCommand ({ 70 | author, 71 | browser, 72 | createdOn, 73 | ip, 74 | modifiedOn, 75 | querystring, 76 | referrer, 77 | testOnly, 78 | text 79 | }) { 80 | return { 81 | headers: { 82 | 'Content-Type': 'application/x-www-form-urlencoded' 83 | }, 84 | url: process.env.DM_SPAM_API_URL, 85 | method: 'post', 86 | data: querystring.stringify({ 87 | blog: 'https://devmastery.com', 88 | user_ip: ip, 89 | user_agent: browser, 90 | referrer, 91 | comment_type: 'comment', 92 | comment_author: author, 93 | comment_content: text, 94 | comment_date_gmt: new Date(createdOn).toISOString(), 95 | comment_post_modified_gmt: new Date(modifiedOn).toISOString(), 96 | blog_lang: 'en', 97 | is_test: testOnly 98 | }) 99 | } 100 | } 101 | 102 | export function normalizeAkismetApiResponse (response) { 103 | return response.data 104 | } 105 | -------------------------------------------------------------------------------- /src/is-questionable/is-questionable.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | buildModerationApiCommand, 3 | normalizeModerationApiResponse, 4 | buildAkismetApiCommand 5 | } from './is-questionable' 6 | import makeFakeComment from '../../__test__/fixtures/comment' 7 | import review from '../../__test__/fixtures/moderation-api/review.json' 8 | import noReview from '../../__test__/fixtures/moderation-api/no-review.json' 9 | import noClassification from '../../__test__/fixtures/moderation-api/no-classification.json' 10 | import dotenv from 'dotenv' 11 | import qs from 'querystring' 12 | dotenv.config() 13 | 14 | describe('Is Questionable', () => { 15 | it('builds a valid Moderator API request', () => { 16 | const expected = { 17 | method: 'post', 18 | data: review.OriginalText, 19 | params: { classify: 'true' }, 20 | headers: { 21 | 'Content-Type': 'text/html', 22 | 'Ocp-Apim-Subscription-Key': process.env.DM_MODERATOR_API_KEY 23 | }, 24 | url: process.env.DM_MODERATOR_API_URL 25 | } 26 | expect(buildModerationApiCommand(review.OriginalText)).toEqual(expected) 27 | }) 28 | it('handles a review recommendation', () => { 29 | expect(normalizeModerationApiResponse({ data: review })).toBe(true) 30 | }) 31 | it('handles a no review recommendation', () => { 32 | expect(normalizeModerationApiResponse({ data: noReview })).toBe(false) 33 | }) 34 | it('handles the lack of any recommendation', () => { 35 | expect(normalizeModerationApiResponse({ data: noClassification })).toBe( 36 | true 37 | ) 38 | }) 39 | it('builds a valid Akismet API request', () => { 40 | const comment = makeFakeComment() 41 | const expected = { 42 | headers: { 43 | 'Content-Type': 'application/x-www-form-urlencoded' 44 | }, 45 | url: process.env.DM_SPAM_API_URL, 46 | method: 'post', 47 | data: qs.stringify({ 48 | blog: 'https://devmastery.com', 49 | user_ip: comment.source.ip, 50 | user_agent: comment.source.browser, 51 | referrer: comment.source.referrer, 52 | comment_type: 'comment', 53 | comment_author: comment.author, 54 | comment_content: comment.text, 55 | comment_date_gmt: new Date(comment.createdOn).toISOString(), 56 | comment_post_modified_gmt: new Date(comment.modifiedOn).toISOString(), 57 | blog_lang: 'en', 58 | is_test: true 59 | }) 60 | } 61 | const actual = buildAkismetApiCommand({ 62 | text: comment.text, 63 | ip: comment.source.ip, 64 | browser: comment.source.browser, 65 | referrer: comment.source.referrer, 66 | author: comment.author, 67 | createdOn: comment.createdOn, 68 | modifiedOn: comment.modifiedOn, 69 | testOnly: true, 70 | querystring: qs 71 | }) 72 | expect(actual).toEqual(expected) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/use-cases/add-comment.js: -------------------------------------------------------------------------------- 1 | import makeComment from '../comment' 2 | export default function makeAddComment ({ commentsDb, handleModeration }) { 3 | return async function addComment (commentInfo) { 4 | const comment = makeComment(commentInfo) 5 | const exists = await commentsDb.findByHash({ hash: comment.getHash() }) 6 | if (exists) { 7 | return exists 8 | } 9 | 10 | const moderated = await handleModeration({ comment }) 11 | const commentSource = moderated.getSource() 12 | return commentsDb.insert({ 13 | author: moderated.getAuthor(), 14 | createdOn: moderated.getCreatedOn(), 15 | hash: moderated.getHash(), 16 | id: moderated.getId(), 17 | modifiedOn: moderated.getModifiedOn(), 18 | postId: moderated.getPostId(), 19 | published: moderated.isPublished(), 20 | replyToId: moderated.getReplyToId(), 21 | source: { 22 | ip: commentSource.getIp(), 23 | browser: commentSource.getBrowser(), 24 | referrer: commentSource.getReferrer() 25 | }, 26 | text: moderated.getText() 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/use-cases/add-comment.spec.js: -------------------------------------------------------------------------------- 1 | import makeAddComment from './add-comment' 2 | import makeHandleModeration from './handle-moderation' 3 | import makeCommentsDb from '../data-access/comments-db' 4 | import makeFakeComment from '../../__test__/fixtures/comment' 5 | import makeDb from '../../__test__/fixtures/db' 6 | 7 | describe('add comment', () => { 8 | let commentsDb 9 | beforeAll(() => { 10 | commentsDb = makeCommentsDb({ makeDb }) 11 | }) 12 | 13 | it('inserts comments in the database', async () => { 14 | const newComment = makeFakeComment() 15 | const handleModeration = makeHandleModeration({ 16 | isQuestionable: () => !newComment.published, 17 | initiateReview: () => {} 18 | }) 19 | const addComment = makeAddComment({ 20 | commentsDb, 21 | handleModeration 22 | }) 23 | const inserted = await addComment(newComment) 24 | expect(inserted).toMatchObject(newComment) 25 | }) 26 | it('does not publish questionable comments', async () => { 27 | const handleModeration = makeHandleModeration({ 28 | isQuestionable: () => true, 29 | initiateReview: () => {} 30 | }) 31 | const addComment = makeAddComment({ 32 | commentsDb, 33 | handleModeration 34 | }) 35 | const inappropriate = makeFakeComment({ text: 'What is this #!@*' }) 36 | const inserted = await addComment(inappropriate) 37 | expect(inserted.published).toBe(false) 38 | }) 39 | it('publishes safe comments', async () => { 40 | const handleModeration = makeHandleModeration({ 41 | isQuestionable: () => false, 42 | initiateReview: () => {} 43 | }) 44 | const addComment = makeAddComment({ 45 | commentsDb, 46 | handleModeration 47 | }) 48 | const appropriare = makeFakeComment({ text: 'What a lovely post' }) 49 | const inserted = await addComment(appropriare) 50 | expect(inserted.published).toBe(true) 51 | }) 52 | it('is idempotent', async () => { 53 | const handleModeration = makeHandleModeration({ 54 | isQuestionable: () => false, 55 | initiateReview: () => {} 56 | }) 57 | const addComment = makeAddComment({ 58 | commentsDb, 59 | handleModeration 60 | }) 61 | const newComment = makeFakeComment({ id: undefined }) 62 | const insertOne = await addComment(newComment) 63 | const insertTwo = await addComment(newComment) 64 | expect(insertOne.id).toBeDefined() 65 | expect(insertOne.id).toBe(insertTwo.id) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/use-cases/edit-comment.js: -------------------------------------------------------------------------------- 1 | import makeComment from '../comment' 2 | export default function makeEditComment ({ commentsDb, handleModeration }) { 3 | return async function editComment ({ id, ...changes } = {}) { 4 | if (!id) { 5 | throw new Error('You must supply an id.') 6 | } 7 | if (!changes.text) { 8 | throw new Error('You must supply text.') 9 | } 10 | const existing = await commentsDb.findById({ id }) 11 | 12 | if (!existing) { 13 | throw new RangeError('Comment not found.') 14 | } 15 | const comment = makeComment({ ...existing, ...changes, modifiedOn: null }) 16 | if (comment.getHash() === existing.hash) { 17 | return existing 18 | } 19 | const moderated = await handleModeration({ comment }) 20 | const updated = await commentsDb.update({ 21 | id: moderated.getId(), 22 | published: moderated.isPublished(), 23 | modifiedOn: moderated.getModifiedOn(), 24 | text: moderated.getText(), 25 | hash: moderated.getHash() 26 | }) 27 | return { ...existing, ...updated } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/use-cases/edit-comment.spec.js: -------------------------------------------------------------------------------- 1 | import makeEditComment from './edit-comment' 2 | import makeFakeComment from '../../__test__/fixtures/comment' 3 | import makeHandleModeration from './handle-moderation' 4 | import makeCommentsDb from '../data-access/comments-db' 5 | import makeDb from '../../__test__/fixtures/db' 6 | 7 | describe('edit comment', () => { 8 | let commentsDb 9 | beforeAll(() => { 10 | commentsDb = makeCommentsDb({ makeDb }) 11 | }) 12 | it('must include an id', () => { 13 | const editComment = makeEditComment({ 14 | commentsDb: { 15 | update: () => { 16 | throw new Error('update should not have been called') 17 | } 18 | }, 19 | isQuestionable: () => { 20 | throw new Error('isQuestionable should not have been called') 21 | } 22 | }) 23 | const commentToEdit = makeFakeComment({ id: undefined }) 24 | expect(editComment(commentToEdit)).rejects.toThrow('You must supply an id.') 25 | }) 26 | it('must include text', () => { 27 | const editComment = makeEditComment({ 28 | commentsDb: { 29 | update: () => { 30 | throw new Error('update should not have been called') 31 | } 32 | }, 33 | handleModeration: () => {} 34 | }) 35 | const commentToEdit = makeFakeComment({ id: undefined }) 36 | expect(editComment(commentToEdit)).rejects.toThrow('You must supply an id.') 37 | }) 38 | it('modifies a comment', async () => { 39 | const handleModeration = makeHandleModeration({ 40 | isQuestionable: () => false, 41 | initiateReview: () => {} 42 | }) 43 | const editComment = makeEditComment({ 44 | commentsDb, 45 | handleModeration 46 | }) 47 | const fakeComment = makeFakeComment({ 48 | modifiedOn: undefined 49 | }) 50 | const inserted = await commentsDb.insert(fakeComment) 51 | const edited = await editComment({ ...fakeComment, text: 'changed' }) 52 | expect(edited.text).toBe('changed') 53 | expect(inserted.modifiedOn).not.toBe(edited.modifiedOn) 54 | expect(edited.hash).toBeDefined() 55 | expect(inserted.hash).not.toBe(edited.hash) 56 | }) 57 | it('does not publish questionable comments', async () => { 58 | const inserted = await commentsDb.insert( 59 | makeFakeComment({ published: true }) 60 | ) 61 | expect(inserted.published).toBe(true) 62 | const handleModeration = makeHandleModeration({ 63 | isQuestionable: () => true, 64 | initiateReview: () => {} 65 | }) 66 | const editComment = makeEditComment({ 67 | commentsDb, 68 | handleModeration 69 | }) 70 | inserted.text = 'What is this #!@*' 71 | const edited = await editComment(inserted) 72 | expect(edited.published).toBe(false) 73 | return commentsDb.remove(inserted) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/use-cases/handle-moderation.js: -------------------------------------------------------------------------------- 1 | export default function makeHandleModeration ({ 2 | isQuestionable, 3 | initiateReview 4 | }) { 5 | return async function handleModeration ({ comment }) { 6 | const shouldModerate = await isQuestionable({ 7 | text: comment.getText(), 8 | ip: comment.getSource().getIp(), 9 | browser: comment.getSource().getBrowser(), 10 | referrer: comment.getSource().getReferrer(), 11 | author: comment.getAuthor(), 12 | createdOn: comment.getCreatedOn(), 13 | modifiedOn: comment.getModifiedOn() 14 | }) 15 | const moderated = { ...comment } 16 | if (shouldModerate) { 17 | initiateReview({ id: moderated.getId(), content: moderated.getText() }) 18 | moderated.unPublish() 19 | } else { 20 | moderated.publish() 21 | } 22 | return moderated 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/use-cases/index.js: -------------------------------------------------------------------------------- 1 | import makeAddComment from './add-comment' 2 | import makeEditComment from './edit-comment' 3 | import makeRemoveComment from './remove-comment' 4 | import makeListComments from './list-comments' 5 | import makeHandleModeration from './handle-moderation' 6 | import commentsDb from '../data-access' 7 | import isQuestionable from '../is-questionable' 8 | 9 | const handleModeration = makeHandleModeration({ 10 | isQuestionable, 11 | initiateReview: async () => {} // TODO: Make real initiate review function. 12 | }) 13 | const addComment = makeAddComment({ commentsDb, handleModeration }) 14 | const editComment = makeEditComment({ commentsDb, handleModeration }) 15 | const listComments = makeListComments({ commentsDb }) 16 | const removeComment = makeRemoveComment({ commentsDb }) 17 | 18 | const commentService = Object.freeze({ 19 | addComment, 20 | editComment, 21 | handleModeration, 22 | listComments, 23 | removeComment 24 | }) 25 | 26 | export default commentService 27 | export { addComment, editComment, listComments, removeComment } 28 | -------------------------------------------------------------------------------- /src/use-cases/list-comments.js: -------------------------------------------------------------------------------- 1 | export default function makeListComments ({ commentsDb }) { 2 | return async function listComments ({ postId } = {}) { 3 | if (!postId) { 4 | throw new Error('You must supply a post id.') 5 | } 6 | const comments = await commentsDb.findByPostId({ 7 | postId, 8 | omitReplies: false 9 | }) 10 | const nestedComments = nest(comments) 11 | return nestedComments 12 | 13 | // If this gets slow introduce caching. 14 | function nest (comments) { 15 | if (comments.length === 0) { 16 | return comments 17 | } 18 | return comments.reduce((nested, comment) => { 19 | comment.replies = comments.filter( 20 | reply => reply.replyToId === comment.id 21 | ) 22 | nest(comment.replies) 23 | if (comment.replyToId == null) { 24 | nested.push(comment) 25 | } 26 | return nested 27 | }, []) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/use-cases/list-comments.spec.js: -------------------------------------------------------------------------------- 1 | import makeGetComments from './list-comments' 2 | import makeCommentsDb from '../data-access/comments-db' 3 | import makeFakeComment from '../../__test__/fixtures/comment' 4 | import makeDb from '../../__test__/fixtures/db' 5 | 6 | describe('get comments', () => { 7 | let commentsDb, getComments 8 | beforeAll(() => { 9 | commentsDb = makeCommentsDb({ makeDb }) 10 | getComments = makeGetComments({ commentsDb }) 11 | }) 12 | 13 | it('requires a post id', () => { 14 | expect(getComments()).rejects.toThrow('You must supply a post id.') 15 | }) 16 | it('gets all comments', async () => { 17 | const firstComment = makeFakeComment({ replyToId: null }) 18 | const secondComment = makeFakeComment({ 19 | replyToId: null, 20 | postId: firstComment.postId 21 | }) 22 | const thirdComment = makeFakeComment({ 23 | replyToId: null, 24 | postId: firstComment.postId 25 | }) 26 | const replyToFirstComment = makeFakeComment({ 27 | replyToId: firstComment.id, 28 | postId: firstComment.postId 29 | }) 30 | const anotherReplyToFirstComment = makeFakeComment({ 31 | replyToId: firstComment.id, 32 | postId: firstComment.postId 33 | }) 34 | const replyToSecondComment = makeFakeComment({ 35 | replyToId: secondComment.id, 36 | postId: firstComment.postId 37 | }) 38 | const comments = [ 39 | firstComment, 40 | secondComment, 41 | thirdComment, 42 | replyToFirstComment, 43 | anotherReplyToFirstComment, 44 | replyToSecondComment 45 | ] 46 | await Promise.all(comments.map(commentsDb.insert)) 47 | const actualGraph = await getComments({ postId: firstComment.postId }) 48 | 49 | const firstCommentFromDb = actualGraph.filter(c => c.id === firstComment.id) 50 | expect(firstCommentFromDb[0].replies.length).toBe(2) 51 | expect(firstCommentFromDb[0].replies).toContainEqual({ 52 | ...replyToFirstComment, 53 | replies: [] 54 | }) 55 | expect(firstCommentFromDb[0].replies).toContainEqual({ 56 | ...anotherReplyToFirstComment, 57 | replies: [] 58 | }) 59 | 60 | const secondCommentFromDb = actualGraph.filter( 61 | c => c.id === secondComment.id 62 | ) 63 | expect(secondCommentFromDb[0].replies.length).toBe(1) 64 | expect(secondCommentFromDb[0].replies).toContainEqual({ 65 | ...replyToSecondComment, 66 | replies: [] 67 | }) 68 | 69 | const thirdCommentFromDb = actualGraph.filter(c => c.id === thirdComment.id) 70 | expect(thirdCommentFromDb[0].replies.length).toBe(0) 71 | return Promise.all(comments.map(commentsDb.remove)) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/use-cases/remove-comment.js: -------------------------------------------------------------------------------- 1 | import makeComment from '../comment' 2 | 3 | export default function makeRemoveComment ({ commentsDb }) { 4 | return async function removeComment ({ id } = {}) { 5 | if (!id) { 6 | throw new Error('You must supply a comment id.') 7 | } 8 | 9 | const commentToDelete = await commentsDb.findById({ id }) 10 | 11 | if (!commentToDelete) { 12 | return deleteNothing() 13 | } 14 | 15 | if (await hasReplies(commentToDelete)) { 16 | return softDelete(commentToDelete) 17 | } 18 | 19 | if (await isOnlyReplyOfDeletedParent(commentToDelete)) { 20 | return deleteCommentAndParent(commentToDelete) 21 | } 22 | 23 | return hardDelete(commentToDelete) 24 | } 25 | 26 | async function hasReplies ({ id: commentId }) { 27 | const replies = await commentsDb.findReplies({ 28 | commentId, 29 | publishedOnly: false 30 | }) 31 | return replies.length > 0 32 | } 33 | 34 | async function isOnlyReplyOfDeletedParent (comment) { 35 | if (!comment.replyToId) { 36 | return false 37 | } 38 | const parent = await commentsDb.findById({ id: comment.replyToId }) 39 | if (parent && makeComment(parent).isDeleted()) { 40 | const replies = await commentsDb.findReplies({ 41 | commentId: parent.id, 42 | publishedOnly: false 43 | }) 44 | return replies.length === 1 45 | } 46 | return false 47 | } 48 | 49 | function deleteNothing () { 50 | return { 51 | deletedCount: 0, 52 | softDelete: false, 53 | message: 'Comment not found, nothing to delete.' 54 | } 55 | } 56 | 57 | async function softDelete (commentInfo) { 58 | const toDelete = makeComment(commentInfo) 59 | toDelete.markDeleted() 60 | await commentsDb.update({ 61 | id: toDelete.getId(), 62 | author: toDelete.getAuthor(), 63 | text: toDelete.getText(), 64 | replyToId: toDelete.getReplyToId(), 65 | postId: toDelete.getPostId() 66 | }) 67 | return { 68 | deletedCount: 1, 69 | softDelete: true, 70 | message: 'Comment has replies. Soft deleted.' 71 | } 72 | } 73 | 74 | async function deleteCommentAndParent (comment) { 75 | await Promise.all([ 76 | commentsDb.remove(comment), 77 | commentsDb.remove({ id: comment.replyToId }) 78 | ]) 79 | return { 80 | deletedCount: 2, 81 | softDelete: false, 82 | message: 'Comment and parent deleted.' 83 | } 84 | } 85 | 86 | async function hardDelete (comment) { 87 | await commentsDb.remove(comment) 88 | return { 89 | deletedCount: 1, 90 | softDelete: false, 91 | message: 'Comment deleted.' 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/use-cases/remove-comment.spec.js: -------------------------------------------------------------------------------- 1 | import makeRemoveComment from './remove-comment' 2 | import makeCommentsDb from '../data-access/comments-db' 3 | import makeFakeComment from '../../__test__/fixtures/comment' 4 | import makeDb from '../../__test__/fixtures/db' 5 | import makeComment from '../comment' 6 | 7 | describe('remove comment', () => { 8 | let commentsDb 9 | beforeAll(() => { 10 | commentsDb = makeCommentsDb({ makeDb }) 11 | }) 12 | it('handles non existent comments', async () => { 13 | const removeComment = makeRemoveComment({ 14 | commentsDb 15 | }) 16 | const fakeComment = makeFakeComment() 17 | const expected = { 18 | deletedCount: 0, 19 | softDelete: false, 20 | message: 'Comment not found, nothing to delete.' 21 | } 22 | const actual = await removeComment(fakeComment) 23 | expect(actual).toEqual(expected) 24 | }) 25 | it('hard deletes comments with zero replies', async () => { 26 | const removeComment = makeRemoveComment({ 27 | commentsDb 28 | }) 29 | 30 | const fakeComment = makeFakeComment() 31 | await commentsDb.insert(fakeComment) 32 | 33 | const found = await commentsDb.findById(fakeComment) 34 | expect(found).toEqual(fakeComment) 35 | 36 | const expected = { 37 | deletedCount: 1, 38 | softDelete: false, 39 | message: 'Comment deleted.' 40 | } 41 | 42 | const actual = await removeComment(fakeComment) 43 | expect(actual).toEqual(expected) 44 | 45 | const notFound = await commentsDb.findById(fakeComment) 46 | expect(notFound).toBe(null) 47 | }) 48 | it('soft deletes comments with 1 or more replies ', async () => { 49 | const removeComment = makeRemoveComment({ 50 | commentsDb 51 | }) 52 | 53 | const fakeComment = makeFakeComment() 54 | await commentsDb.insert(fakeComment) 55 | 56 | const fakeCommentReply = makeFakeComment({ 57 | replyToId: fakeComment.id 58 | }) 59 | await commentsDb.insert(fakeCommentReply) 60 | 61 | const expected = { 62 | deletedCount: 1, 63 | softDelete: true, 64 | message: 'Comment has replies. Soft deleted.' 65 | } 66 | const actual = await removeComment(fakeComment) 67 | expect(actual).toEqual(expected) 68 | 69 | const deleted = await commentsDb.findById(fakeComment) 70 | expect(makeComment(deleted).isDeleted()).toBe(true) 71 | await commentsDb.remove(fakeCommentReply) 72 | await commentsDb.remove(fakeComment) 73 | }) 74 | it('hard deletes a comment and its deleted parent when there are no other replies', async () => { 75 | const removeComment = makeRemoveComment({ 76 | commentsDb 77 | }) 78 | 79 | const fakeComment = makeFakeComment() 80 | 81 | const fakeReply = makeFakeComment({ 82 | replyToId: fakeComment.id, 83 | postId: fakeComment.postId, 84 | published: true 85 | }) 86 | console.log({ fakeReply }) 87 | const [insertedParent, insertedReply] = await Promise.all([ 88 | commentsDb.insert(fakeComment), 89 | commentsDb.insert(fakeReply) 90 | ]) 91 | const parentDelete = await removeComment(insertedParent) 92 | expect(parentDelete.softDelete).toBe(true) 93 | 94 | const expected = { 95 | deletedCount: 2, 96 | softDelete: false, 97 | message: 'Comment and parent deleted.' 98 | } 99 | const actual = await removeComment(insertedReply) 100 | expect(actual).toEqual(expected) 101 | 102 | await commentsDb.remove(fakeReply) 103 | await commentsDb.remove(fakeComment) 104 | }) 105 | }) 106 | --------------------------------------------------------------------------------