WIP
This commit is contained in:
committed by
Ulises Gascon
parent
91ba1f5054
commit
9dd233c371
+237
@@ -0,0 +1,237 @@
|
|||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/node,visualstudiocode,macos,windows,linux
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=node,visualstudiocode,macos,windows,linux
|
||||||
|
|
||||||
|
### Linux ###
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# KDE directory preferences
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
### macOS ###
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
### macOS Patch ###
|
||||||
|
# iCloud generated files
|
||||||
|
*.icloud
|
||||||
|
|
||||||
|
### Node ###
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
### Node Patch ###
|
||||||
|
# Serverless Webpack directories
|
||||||
|
.webpack/
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
# SvelteKit build / generate output
|
||||||
|
.svelte-kit
|
||||||
|
|
||||||
|
### VisualStudioCode ###
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
### VisualStudioCode Patch ###
|
||||||
|
# Ignore all local history of files
|
||||||
|
.history
|
||||||
|
.ionide
|
||||||
|
|
||||||
|
### Windows ###
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode,macos,windows,linux
|
||||||
@@ -1 +1,11 @@
|
|||||||
# NodeJS-for-Beginners
|
# NodeJS for Beginners
|
||||||
|
|
||||||
|
## Project: Whispering
|
||||||
|
|
||||||
|
- [step0](step0/): At the start of "The project: Kickoff" in chapter 11
|
||||||
|
- [step1](step1/): At the end of "Building an API Rest" in chapter 11
|
||||||
|
- [step2](step2/): At the end of "Testing with supertest" in chapter 11
|
||||||
|
- [step3](step3/): At the end of the chapter 12
|
||||||
|
- [step4](step4/): At the end of the chapter 13
|
||||||
|
- [step5](step5/): At the end of the chapter 16
|
||||||
|
- [step5](step6/): At the end of the chapter 17
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "presets": ["@babel/preset-env"] }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20.11.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/node_test/']
|
||||||
|
}
|
||||||
Generated
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "nodejs-for-beginners",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "nodejs-for-beginners",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "nodejs-for-beginners",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"standard": {
|
||||||
|
"env": [ "jest" ],
|
||||||
|
"ignore": [ "public/*.js" ]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// == Selectors ==
|
||||||
|
const whispers = document.getElementById('whispers')
|
||||||
|
const whisperCreateButton = document.getElementById('whisper-create')
|
||||||
|
|
||||||
|
// == Event Listeners ==
|
||||||
|
whispers.addEventListener('click', event => {
|
||||||
|
if(event.target.tagName === 'BUTTON') {
|
||||||
|
const button = event.target
|
||||||
|
const article = event.target.closest('article')
|
||||||
|
const action = button.dataset.action
|
||||||
|
const id = article.dataset.id
|
||||||
|
const message = article.querySelector('p').innerText
|
||||||
|
if(action === 'edit') requestUserEdit(id, message)
|
||||||
|
if(action === 'delete') requestUserDelete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
whisperCreateButton.addEventListener('click', event => {
|
||||||
|
const message = prompt("What's your whisper?")
|
||||||
|
if(message) {
|
||||||
|
createWhisper(message)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// === Functions ==
|
||||||
|
// -- API --
|
||||||
|
const fetchAllWhispers = () => fetch('http://localhost:3000/api/v1/whisper').then((response) => response.json())
|
||||||
|
const deleteWhisper = (id) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, { method: 'DELETE' })
|
||||||
|
const updateWhisper = (id, message) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) })
|
||||||
|
const createWhisper = (message) => fetch('http://localhost:3000/api/v1/whisper', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) })
|
||||||
|
|
||||||
|
// -- UI --
|
||||||
|
const refreshWhispers = data => whispers.innerHTML = data
|
||||||
|
.reverse()
|
||||||
|
.map(whisper => {
|
||||||
|
return `
|
||||||
|
<article data-id="${whisper.id}">
|
||||||
|
<div class="actions">
|
||||||
|
<button data-action="edit">✏️</button>
|
||||||
|
<button data-action="delete">❌</button>
|
||||||
|
</div>
|
||||||
|
<p>${whisper.message}</p>
|
||||||
|
</article>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const refreshAllUI = () => fetchAllWhispers().then(refreshWhispers)
|
||||||
|
|
||||||
|
const requestUserEdit = (id, message) => {
|
||||||
|
const newMessage = prompt("Edit the Whisper", message);
|
||||||
|
if(newMessage && newMessage !== message) {
|
||||||
|
updateWhisper(id, newMessage)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
console.log("Request User Edit", id, message)
|
||||||
|
}
|
||||||
|
const requestUserDelete = (id) => {
|
||||||
|
const confirmation = confirm("Are you sure you want to delete this whisper?");
|
||||||
|
if(confirmation) {
|
||||||
|
deleteWhisper(id)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Initialization ==
|
||||||
|
refreshAllUI()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
<li><a href="#">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<button type="button" class="block" id="whisper-create">Spread a whisper 🤭</button>
|
||||||
|
<div id="whispers"></div>
|
||||||
|
</main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,110 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
background-color: #343e8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li {
|
||||||
|
float: right;
|
||||||
|
margin-right: 4%;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li:last-child {
|
||||||
|
float: left;
|
||||||
|
margin-left: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whispers {
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
background: #f8f6f6;
|
||||||
|
border: 2px solid #343e8b;
|
||||||
|
margin: 1.5em 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
article p {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions > button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 3px;
|
||||||
|
border: none;
|
||||||
|
background-color: #f8f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
width: 60%;
|
||||||
|
border: none;
|
||||||
|
background-color: #343e8b;
|
||||||
|
padding: 0.5em 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>Welcome to Whispering!</h1>
|
||||||
|
<figure>
|
||||||
|
<img src="people.jpg" alt="three people sitting at the table laughing together" />
|
||||||
|
<figcaption>Photo by <a href="https://unsplash.com/photos/g1Kr4Ozfoac">Brooke Cagle</a> from <a
|
||||||
|
href="https://unsplash.com/">Unsplash</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h2>What is Whispering?</h2>
|
||||||
|
<p>Whispering is a microblogging platform that allows you to share your thoughts with the world and learn
|
||||||
|
Node.js on the way.</p>
|
||||||
|
|
||||||
|
<h2>Community live ⚡️</h2>
|
||||||
|
<p>Currently there are <%= whispers.length %> whispers available</p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "presets": ["@babel/preset-env"] }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20.11.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { app } from './server.js'
|
||||||
|
|
||||||
|
const port = 3000
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Running in http://localhost:${port}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/node_test/']
|
||||||
|
}
|
||||||
Generated
+9726
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "nodejs-for-beginners",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"test": "jest",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"lint": "standard",
|
||||||
|
"lint:fix": "standard --fix"
|
||||||
|
},
|
||||||
|
"standard": {
|
||||||
|
"env": [
|
||||||
|
"jest"
|
||||||
|
],
|
||||||
|
"ignore": [
|
||||||
|
"public/*.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"ejs": "^3.1.9",
|
||||||
|
"express": "^4.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-env": "^7.24.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"standard": "^17.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// == Selectors ==
|
||||||
|
const whispers = document.getElementById('whispers')
|
||||||
|
const whisperCreateButton = document.getElementById('whisper-create')
|
||||||
|
|
||||||
|
// == Event Listeners ==
|
||||||
|
whispers.addEventListener('click', event => {
|
||||||
|
if (event.target.tagName === 'BUTTON') {
|
||||||
|
const button = event.target
|
||||||
|
const article = event.target.closest('article')
|
||||||
|
const action = button.dataset.action
|
||||||
|
const id = article.dataset.id
|
||||||
|
const message = article.querySelector('p').innerText
|
||||||
|
if (action === 'edit') requestUserEdit(id, message)
|
||||||
|
if (action === 'delete') requestUserDelete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
whisperCreateButton.addEventListener('click', event => {
|
||||||
|
const message = prompt("What's your whisper?")
|
||||||
|
if (message) {
|
||||||
|
createWhisper(message)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// === Functions ==
|
||||||
|
// -- API --
|
||||||
|
const fetchAllWhispers = () => fetch('http://localhost:3000/api/v1/whisper').then((response) => response.json())
|
||||||
|
const deleteWhisper = (id) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, { method: 'DELETE' })
|
||||||
|
const updateWhisper = (id, message) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) })
|
||||||
|
const createWhisper = (message) => fetch('http://localhost:3000/api/v1/whisper', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) })
|
||||||
|
|
||||||
|
// -- UI --
|
||||||
|
const refreshWhispers = data => whispers.innerHTML = data
|
||||||
|
.reverse()
|
||||||
|
.map(whisper => {
|
||||||
|
return `
|
||||||
|
<article data-id="${whisper.id}">
|
||||||
|
<div class="actions">
|
||||||
|
<button data-action="edit">✏️</button>
|
||||||
|
<button data-action="delete">❌</button>
|
||||||
|
</div>
|
||||||
|
<p>${whisper.message}</p>
|
||||||
|
</article>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const refreshAllUI = () => fetchAllWhispers().then(refreshWhispers)
|
||||||
|
|
||||||
|
const requestUserEdit = (id, message) => {
|
||||||
|
const newMessage = prompt('Edit the Whisper', message)
|
||||||
|
if (newMessage && newMessage !== message) {
|
||||||
|
updateWhisper(id, newMessage)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
console.log('Request User Edit', id, message)
|
||||||
|
}
|
||||||
|
const requestUserDelete = (id) => {
|
||||||
|
const confirmation = confirm('Are you sure you want to delete this whisper?')
|
||||||
|
if (confirmation) {
|
||||||
|
deleteWhisper(id)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Initialization ==
|
||||||
|
refreshAllUI()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
<li><a href="#">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<button type="button" class="block" id="whisper-create">Spread a whisper 🤭</button>
|
||||||
|
<div id="whispers"></div>
|
||||||
|
</main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,110 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
background-color: #343e8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li {
|
||||||
|
float: right;
|
||||||
|
margin-right: 4%;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li:last-child {
|
||||||
|
float: left;
|
||||||
|
margin-left: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whispers {
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
background: #f8f6f6;
|
||||||
|
border: 2px solid #343e8b;
|
||||||
|
margin: 1.5em 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
article p {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions > button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 3px;
|
||||||
|
border: none;
|
||||||
|
background-color: #f8f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
width: 60%;
|
||||||
|
border: none;
|
||||||
|
background-color: #343e8b;
|
||||||
|
padding: 0.5em 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import bodyParser from 'body-parser'
|
||||||
|
import { getAll } from './store.js'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(express.static('public'))
|
||||||
|
app.use(bodyParser.json())
|
||||||
|
app.set('view engine', 'ejs')
|
||||||
|
|
||||||
|
app.get('/about', async (req, res) => {
|
||||||
|
const whispers = await getAll()
|
||||||
|
res.render('about', { whispers })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/whisper', (req, res) => {
|
||||||
|
res.json([])
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/whisper/:id', (req, res) => {
|
||||||
|
const id = parseInt(req.params.id)
|
||||||
|
res.json({ id })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/v1/whisper', (req, res) => {
|
||||||
|
res.status(201).json(req.body)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/v1/whisper/:id', (req, res) => {
|
||||||
|
// const id = parseInt(req.params.id)
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/v1/whisper/:id', (req, res) => {
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
export { app }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const filename = path.join(process.cwd(), 'db.json')
|
||||||
|
|
||||||
|
const saveChanges = data => fs.writeFile(filename, JSON.stringify(data))
|
||||||
|
const readData = async () => {
|
||||||
|
const data = await fs.readFile(filename, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAll = readData
|
||||||
|
|
||||||
|
const getById = async (id) => {
|
||||||
|
const data = await readData()
|
||||||
|
return data.find(item => item.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = async (message) => {
|
||||||
|
const data = await readData()
|
||||||
|
const newItem = { message, id: data.length + 1 }
|
||||||
|
await saveChanges(data.concat([newItem]))
|
||||||
|
return newItem
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateById = async (id, message) => {
|
||||||
|
const data = await readData()
|
||||||
|
const newData = data.map(current => {
|
||||||
|
if (current.id === id) {
|
||||||
|
return { ...current, message }
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
})
|
||||||
|
|
||||||
|
await saveChanges(newData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteById = async id => {
|
||||||
|
const data = await readData()
|
||||||
|
await saveChanges(data
|
||||||
|
.filter(current => current.id !== id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getAll, getById, create, updateById, deleteById }
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { getAll, getById, create, updateById, deleteById } from '../store.js'
|
||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const dbPath = join(process.cwd(), 'db.json')
|
||||||
|
const restoreDb = () => writeFileSync(dbPath, JSON.stringify([]))
|
||||||
|
const populateDb = (data) => writeFileSync(dbPath, JSON.stringify(data))
|
||||||
|
const fixtures = [{ id: 1, message: 'test' }, { id: 2, message: 'hello world' }]
|
||||||
|
const inventedId = 12345
|
||||||
|
const existingId = fixtures[0].id
|
||||||
|
|
||||||
|
describe('store', () => {
|
||||||
|
beforeEach(() => populateDb(fixtures))
|
||||||
|
afterAll(restoreDb)
|
||||||
|
describe('getAll', () => {
|
||||||
|
it("Should return an empty array when there's no data", async () => {
|
||||||
|
restoreDb()
|
||||||
|
const data = await getAll()
|
||||||
|
expect(data).toEqual([])
|
||||||
|
})
|
||||||
|
it('Should return an array with one item when there is one item', async () => {
|
||||||
|
const data = await getAll()
|
||||||
|
expect(data).toEqual(fixtures)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('getById', () => {
|
||||||
|
it('Should return undefined when there is no item with the given id', async () => {
|
||||||
|
const item = await getById(inventedId)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should return the item with the given id', async () => {
|
||||||
|
const item = await getById(fixtures[0].id)
|
||||||
|
expect(item).toEqual(fixtures[0])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('create', () => {
|
||||||
|
it('Should return the created item', async () => {
|
||||||
|
const newItem = { id: fixtures.length + 1, message: 'test 3' }
|
||||||
|
const item = await create(newItem.message)
|
||||||
|
expect(item).toEqual(newItem)
|
||||||
|
})
|
||||||
|
it('Should add the item to the db', async () => {
|
||||||
|
const newItem = { id: fixtures.length + 1, message: 'test 3' }
|
||||||
|
const { id } = await create(newItem.message)
|
||||||
|
const item = await getById(id)
|
||||||
|
expect(item).toEqual(newItem)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('updateById', () => {
|
||||||
|
it('Should return undefined when there is no item with the given id', async () => {
|
||||||
|
const item = await updateById(inventedId)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should not return the updated item', async () => {
|
||||||
|
const updatedItem = { id: existingId, message: 'updated' }
|
||||||
|
const item = await updateById(updatedItem.id, updatedItem.message)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should update the item in the db', async () => {
|
||||||
|
const updatedItem = { id: existingId, message: 'updated' }
|
||||||
|
await updateById(updatedItem.id, updatedItem.message)
|
||||||
|
const item = await getById(existingId)
|
||||||
|
expect(item).toEqual(updatedItem)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('deleteById', () => {
|
||||||
|
it('Should return undefined when there is no item with the given id', async () => {
|
||||||
|
const item = await deleteById(inventedId)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should not return the deleted item', async () => {
|
||||||
|
const item = await deleteById(existingId)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should delete the item from the db', async () => {
|
||||||
|
await deleteById(existingId)
|
||||||
|
const items = await getAll()
|
||||||
|
expect(items).toEqual(fixtures.filter(item => item.id !== existingId))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>Welcome to Whispering!</h1>
|
||||||
|
<figure>
|
||||||
|
<img src="people.jpg" alt="three people sitting at the table laughing together" />
|
||||||
|
<figcaption>Photo by <a href="https://unsplash.com/photos/g1Kr4Ozfoac">Brooke Cagle</a> from <a
|
||||||
|
href="https://unsplash.com/">Unsplash</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h2>What is Whispering?</h2>
|
||||||
|
<p>Whispering is a microblogging platform that allows you to share your thoughts with the world and learn
|
||||||
|
Node.js on the way.</p>
|
||||||
|
|
||||||
|
<h2>Community live ⚡️</h2>
|
||||||
|
<p>Currently there are <%= whispers.length %> whispers available</p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "presets": ["@babel/preset-env"] }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20.11.0
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[{
|
||||||
|
"id": 1,
|
||||||
|
"message": "Hello World! This is my first Whisper. Yay! 🎉"
|
||||||
|
}, {
|
||||||
|
"id": 2,
|
||||||
|
"message": "It is raining now... 🌧️"
|
||||||
|
}, {
|
||||||
|
"id": 3,
|
||||||
|
"message": "I am learning Node.js and I love it! 🙌"
|
||||||
|
}]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { app } from './server.js'
|
||||||
|
|
||||||
|
const port = 3000
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Running in http://localhost:${port}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/node_test/']
|
||||||
|
}
|
||||||
Generated
+9943
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "nodejs-for-beginners",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"test": "jest",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"lint": "standard",
|
||||||
|
"lint:fix": "standard --fix"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"standard": {
|
||||||
|
"env": [ "jest" ],
|
||||||
|
"ignore": [ "public/*.js" ]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"ejs": "^3.1.9",
|
||||||
|
"express": "^4.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-env": "^7.24.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"standard": "^17.1.0",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// == Selectors ==
|
||||||
|
const whispers = document.getElementById('whispers')
|
||||||
|
const whisperCreateButton = document.getElementById('whisper-create')
|
||||||
|
|
||||||
|
// == Event Listeners ==
|
||||||
|
whispers.addEventListener('click', event => {
|
||||||
|
if(event.target.tagName === 'BUTTON') {
|
||||||
|
const button = event.target
|
||||||
|
const article = event.target.closest('article')
|
||||||
|
const action = button.dataset.action
|
||||||
|
const id = article.dataset.id
|
||||||
|
const message = article.querySelector('p').innerText
|
||||||
|
if(action === 'edit') requestUserEdit(id, message)
|
||||||
|
if(action === 'delete') requestUserDelete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
whisperCreateButton.addEventListener('click', event => {
|
||||||
|
const message = prompt("What's your whisper?")
|
||||||
|
if(message) {
|
||||||
|
createWhisper(message)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// === Functions ==
|
||||||
|
// -- API --
|
||||||
|
const fetchAllWhispers = () => fetch('http://localhost:3000/api/v1/whisper').then((response) => response.json())
|
||||||
|
const deleteWhisper = (id) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, { method: 'DELETE' })
|
||||||
|
const updateWhisper = (id, message) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) })
|
||||||
|
const createWhisper = (message) => fetch('http://localhost:3000/api/v1/whisper', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) })
|
||||||
|
|
||||||
|
// -- UI --
|
||||||
|
const refreshWhispers = data => whispers.innerHTML = data
|
||||||
|
.reverse()
|
||||||
|
.map(whisper => {
|
||||||
|
return `
|
||||||
|
<article data-id="${whisper.id}">
|
||||||
|
<div class="actions">
|
||||||
|
<button data-action="edit">✏️</button>
|
||||||
|
<button data-action="delete">❌</button>
|
||||||
|
</div>
|
||||||
|
<p>${whisper.message}</p>
|
||||||
|
</article>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const refreshAllUI = () => fetchAllWhispers().then(refreshWhispers)
|
||||||
|
|
||||||
|
const requestUserEdit = (id, message) => {
|
||||||
|
const newMessage = prompt("Edit the Whisper", message);
|
||||||
|
if(newMessage && newMessage !== message) {
|
||||||
|
updateWhisper(id, newMessage)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
console.log("Request User Edit", id, message)
|
||||||
|
}
|
||||||
|
const requestUserDelete = (id) => {
|
||||||
|
const confirmation = confirm("Are you sure you want to delete this whisper?");
|
||||||
|
if(confirmation) {
|
||||||
|
deleteWhisper(id)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Initialization ==
|
||||||
|
refreshAllUI()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
<li><a href="#">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<button type="button" class="block" id="whisper-create">Spread a whisper 🤭</button>
|
||||||
|
<div id="whispers"></div>
|
||||||
|
</main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,110 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
background-color: #343e8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li {
|
||||||
|
float: right;
|
||||||
|
margin-right: 4%;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li:last-child {
|
||||||
|
float: left;
|
||||||
|
margin-left: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whispers {
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
background: #f8f6f6;
|
||||||
|
border: 2px solid #343e8b;
|
||||||
|
margin: 1.5em 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
article p {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions > button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 3px;
|
||||||
|
border: none;
|
||||||
|
background-color: #f8f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
width: 60%;
|
||||||
|
border: none;
|
||||||
|
background-color: #343e8b;
|
||||||
|
padding: 0.5em 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import bodyParser from 'body-parser'
|
||||||
|
import { getAll, getById, create, updateById, deleteById } from './store.js'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(express.static('public'))
|
||||||
|
app.use(bodyParser.json())
|
||||||
|
app.set('view engine', 'ejs')
|
||||||
|
|
||||||
|
app.get('/about', async (req, res) => {
|
||||||
|
const whispers = await getAll()
|
||||||
|
res.render('about', { whispers })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/whisper', async (req, res) => {
|
||||||
|
const whispers = await getAll()
|
||||||
|
res.json(whispers)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/whisper/:id', async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id)
|
||||||
|
const whisper = await getById(id)
|
||||||
|
if (!whisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
} else {
|
||||||
|
res.json(whisper)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/v1/whisper', async (req, res) => {
|
||||||
|
const { message } = req.body
|
||||||
|
if (!message) {
|
||||||
|
res.sendStatus(400)
|
||||||
|
} else {
|
||||||
|
const whisper = await create(message)
|
||||||
|
res.status(201).json(whisper)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/v1/whisper/:id', async (req, res) => {
|
||||||
|
const { message } = req.body
|
||||||
|
const id = parseInt(req.params.id)
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
res.sendStatus(400)
|
||||||
|
} else {
|
||||||
|
const whisper = await getById(id)
|
||||||
|
if (!whisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
} else {
|
||||||
|
await updateById(id, message)
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/v1/whisper/:id', async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id)
|
||||||
|
const whisper = await getById(id)
|
||||||
|
|
||||||
|
if (!whisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteById(id)
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
export { app }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const filename = path.join(process.cwd(), 'db.json')
|
||||||
|
|
||||||
|
const saveChanges = data => fs.writeFile(filename, JSON.stringify(data))
|
||||||
|
|
||||||
|
const readData = async () => {
|
||||||
|
const data = await fs.readFile(filename, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAll = readData
|
||||||
|
|
||||||
|
const getById = async (id) => {
|
||||||
|
const data = await readData()
|
||||||
|
return data.find(item => item.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = async (message) => {
|
||||||
|
const data = await readData()
|
||||||
|
const newItem = { message, id: data.length + 1 }
|
||||||
|
await saveChanges(data.concat([newItem]))
|
||||||
|
return newItem
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateById = async (id, message) => {
|
||||||
|
const data = await readData()
|
||||||
|
const newData = data.map(current => {
|
||||||
|
if (current.id === id) {
|
||||||
|
return { ...current, message }
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
})
|
||||||
|
|
||||||
|
await saveChanges(newData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteById = async id => {
|
||||||
|
const data = await readData()
|
||||||
|
await saveChanges(data
|
||||||
|
.filter(current => current.id !== id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getAll, getById, create, updateById, deleteById }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const whispers = [{ id: 1, message: 'test' }, { id: 2, message: 'hello world' }]
|
||||||
|
const inventedId = 12345
|
||||||
|
const existingId = whispers[0].id
|
||||||
|
|
||||||
|
export {
|
||||||
|
whispers,
|
||||||
|
inventedId,
|
||||||
|
existingId
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import supertest from 'supertest'
|
||||||
|
import { app } from '../server'
|
||||||
|
import { restoreDb, populateDb } from './utils.js'
|
||||||
|
import { whispers, inventedId, existingId } from './fixtures.js'
|
||||||
|
import { getById } from '../store'
|
||||||
|
|
||||||
|
describe('Server', () => {
|
||||||
|
beforeEach(() => populateDb(whispers))
|
||||||
|
afterAll(restoreDb)
|
||||||
|
describe('GET /api/v1/whisper', () => {
|
||||||
|
it("Should return an empty array when there's no data", async () => {
|
||||||
|
await restoreDb() // empty the db
|
||||||
|
const response = await supertest(app).get('/api/v1/whisper')
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual([])
|
||||||
|
})
|
||||||
|
it('Should return all the whispers', async () => {
|
||||||
|
const response = await supertest(app).get('/api/v1/whisper')
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual(whispers)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('GET /api/v1/whisper/:id', () => {
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app).get(`/api/v1/whisper/${inventedId}`)
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a whisper details', async () => {
|
||||||
|
const response = await supertest(app).get(`/api/v1/whisper/${existingId}`)
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual(whispers.find(w => w.id === existingId))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('POST /api/v1/whisper', () => {
|
||||||
|
it('Should return a 400 when the body is empty', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.send({})
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the body is invalid', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.send({ invented: 'This is a new whisper' })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 201 when the whisper is created', async () => {
|
||||||
|
const newWhisper = { id: whispers.length + 1, message: 'This is a new whisper' }
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.send({ message: newWhisper.message })
|
||||||
|
|
||||||
|
// HTTP Response
|
||||||
|
expect(response.status).toBe(201)
|
||||||
|
expect(response.body).toEqual(newWhisper)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(newWhisper.id)
|
||||||
|
expect(storedWhisper).toStrictEqual(newWhisper)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('PUT /api/v1/whisper/:id', () => {
|
||||||
|
it('Should return a 400 when the body is empty', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.send({})
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the body is invalid', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.send({ invented: 'This a new field' })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${inventedId}`)
|
||||||
|
.send({ message: 'Whisper updated' })
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a 200 when the whisper is updated', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.send({ message: 'Whisper updated' })
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(existingId)
|
||||||
|
expect(storedWhisper).toStrictEqual({ id: existingId, message: 'Whisper updated' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('DELETE /api/v1/whisper/:id', () => {
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app).delete(`/api/v1/whisper/${inventedId}`)
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a 200 when the whisper is deleted', async () => {
|
||||||
|
const response = await supertest(app).delete(`/api/v1/whisper/${existingId}`)
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(existingId)
|
||||||
|
expect(storedWhisper).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { getAll, getById, create, updateById, deleteById } from '../store.js'
|
||||||
|
import { restoreDb, populateDb } from './utils.js'
|
||||||
|
import { whispers, inventedId, existingId } from './fixtures.js'
|
||||||
|
|
||||||
|
describe('store', () => {
|
||||||
|
beforeEach(() => populateDb(whispers))
|
||||||
|
afterAll(restoreDb)
|
||||||
|
describe('getAll', () => {
|
||||||
|
it("Should return an empty array when there's no data", async () => {
|
||||||
|
restoreDb()
|
||||||
|
const data = await getAll()
|
||||||
|
expect(data).toEqual([])
|
||||||
|
})
|
||||||
|
it('Should return an array with one item when there is one item', async () => {
|
||||||
|
const data = await getAll()
|
||||||
|
expect(data).toEqual(whispers)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('getById', () => {
|
||||||
|
it('Should return undefined when there is no item with the given id', async () => {
|
||||||
|
const item = await getById(inventedId)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should return the item with the given id', async () => {
|
||||||
|
const item = await getById(whispers[0].id)
|
||||||
|
expect(item).toEqual(whispers[0])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('create', () => {
|
||||||
|
it('Should return the created item', async () => {
|
||||||
|
const newItem = { id: whispers.length + 1, message: 'test 3' }
|
||||||
|
const item = await create(newItem.message)
|
||||||
|
expect(item).toEqual(newItem)
|
||||||
|
})
|
||||||
|
it('Should add the item to the db', async () => {
|
||||||
|
const newItem = { id: whispers.length + 1, message: 'test 3' }
|
||||||
|
const { id } = await create(newItem.message)
|
||||||
|
const item = await getById(id)
|
||||||
|
expect(item).toEqual(newItem)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('updateById', () => {
|
||||||
|
it('Should return undefined when there is no item with the given id', async () => {
|
||||||
|
const item = await updateById(inventedId)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should not return the updated item', async () => {
|
||||||
|
const updatedItem = { id: existingId, message: 'updated' }
|
||||||
|
const item = await updateById(updatedItem.id, updatedItem.message)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should update the item in the db', async () => {
|
||||||
|
const updatedItem = { id: existingId, message: 'updated' }
|
||||||
|
await updateById(updatedItem.id, updatedItem.message)
|
||||||
|
const item = await getById(existingId)
|
||||||
|
expect(item).toEqual(updatedItem)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('deleteById', () => {
|
||||||
|
it('Should return undefined when there is no item with the given id', async () => {
|
||||||
|
const item = await deleteById(inventedId)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should not return the deleted item', async () => {
|
||||||
|
const item = await deleteById(existingId)
|
||||||
|
expect(item).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('Should delete the item from the db', async () => {
|
||||||
|
await deleteById(existingId)
|
||||||
|
const items = await getAll()
|
||||||
|
expect(items).toEqual(whispers.filter(item => item.id !== existingId))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const dbPath = join(process.cwd(), 'db.json')
|
||||||
|
const restoreDb = () => writeFileSync(dbPath, JSON.stringify([]))
|
||||||
|
const populateDb = (data) => writeFileSync(dbPath, JSON.stringify(data))
|
||||||
|
|
||||||
|
export { restoreDb, populateDb }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>Welcome to Whispering!</h1>
|
||||||
|
<figure>
|
||||||
|
<img src="people.jpg" alt="three people sitting at the table laughing together" />
|
||||||
|
<figcaption>Photo by <a href="https://unsplash.com/photos/g1Kr4Ozfoac">Brooke Cagle</a> from <a
|
||||||
|
href="https://unsplash.com/">Unsplash</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h2>What is Whispering?</h2>
|
||||||
|
<p>Whispering is a microblogging platform that allows you to share your thoughts with the world and learn
|
||||||
|
Node.js on the way.</p>
|
||||||
|
|
||||||
|
<h2>Community live ⚡️</h2>
|
||||||
|
<p>Currently there are <%= whispers.length %> whispers available</p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "presets": ["@babel/preset-env"] }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20.11.0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
mongoose.set('toJSON', {
|
||||||
|
virtuals: true,
|
||||||
|
transform: (doc, converted) => {
|
||||||
|
delete converted._id
|
||||||
|
delete converted.__v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const whisperSchema = new mongoose.Schema({
|
||||||
|
message: String
|
||||||
|
})
|
||||||
|
|
||||||
|
const Whisper = mongoose.model('Whisper', whisperSchema)
|
||||||
|
|
||||||
|
export {
|
||||||
|
Whisper
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
container_name: whispering-database
|
||||||
|
image: mongo:7.0
|
||||||
|
ports:
|
||||||
|
- '27017:27017'
|
||||||
|
volumes:
|
||||||
|
- db-storage:/data/db
|
||||||
|
volumes:
|
||||||
|
db-storage:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { app } from './server.js'
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const port = process.env.PORT
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI)
|
||||||
|
console.log('Connected to MongoDB')
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Running in http://localhost:${port}`)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/node_test/'],
|
||||||
|
coveragePathIgnorePatterns: [
|
||||||
|
'<rootDir>/tests/'
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+10180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "nodejs-for-beginners",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --require dotenv/config index.js",
|
||||||
|
"test": "jest --setupFiles dotenv/config",
|
||||||
|
"test:coverage": "jest --coverage --setupFiles dotenv/config",
|
||||||
|
"lint": "standard",
|
||||||
|
"lint:fix": "standard --fix",
|
||||||
|
"infra:start": "docker-compose up -d --build",
|
||||||
|
"infra:stop": "docker-compose down --remove-orphans"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"standard": {
|
||||||
|
"env": [
|
||||||
|
"jest"
|
||||||
|
],
|
||||||
|
"ignore": [
|
||||||
|
"public/*.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"ejs": "^3.1.9",
|
||||||
|
"express": "^4.18.3",
|
||||||
|
"mongoose": "7.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-env": "^7.24.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"standard": "^17.1.0",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// == Selectors ==
|
||||||
|
const whispers = document.getElementById('whispers')
|
||||||
|
const whisperCreateButton = document.getElementById('whisper-create')
|
||||||
|
|
||||||
|
// == Event Listeners ==
|
||||||
|
whispers.addEventListener('click', event => {
|
||||||
|
if(event.target.tagName === 'BUTTON') {
|
||||||
|
const button = event.target
|
||||||
|
const article = event.target.closest('article')
|
||||||
|
const action = button.dataset.action
|
||||||
|
const id = article.dataset.id
|
||||||
|
const message = article.querySelector('p').innerText
|
||||||
|
if(action === 'edit') requestUserEdit(id, message)
|
||||||
|
if(action === 'delete') requestUserDelete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
whisperCreateButton.addEventListener('click', event => {
|
||||||
|
const message = prompt("What's your whisper?")
|
||||||
|
if(message) {
|
||||||
|
createWhisper(message)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// === Functions ==
|
||||||
|
// -- API --
|
||||||
|
const fetchAllWhispers = () => fetch('http://localhost:3000/api/v1/whisper').then((response) => response.json())
|
||||||
|
const deleteWhisper = (id) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, { method: 'DELETE' })
|
||||||
|
const updateWhisper = (id, message) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) })
|
||||||
|
const createWhisper = (message) => fetch('http://localhost:3000/api/v1/whisper', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) })
|
||||||
|
|
||||||
|
// -- UI --
|
||||||
|
const refreshWhispers = data => whispers.innerHTML = data
|
||||||
|
.reverse()
|
||||||
|
.map(whisper => {
|
||||||
|
return `
|
||||||
|
<article data-id="${whisper.id}">
|
||||||
|
<div class="actions">
|
||||||
|
<button data-action="edit">✏️</button>
|
||||||
|
<button data-action="delete">❌</button>
|
||||||
|
</div>
|
||||||
|
<p>${whisper.message}</p>
|
||||||
|
</article>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const refreshAllUI = () => fetchAllWhispers().then(refreshWhispers)
|
||||||
|
|
||||||
|
const requestUserEdit = (id, message) => {
|
||||||
|
const newMessage = prompt("Edit the Whisper", message);
|
||||||
|
if(newMessage && newMessage !== message) {
|
||||||
|
updateWhisper(id, newMessage)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
console.log("Request User Edit", id, message)
|
||||||
|
}
|
||||||
|
const requestUserDelete = (id) => {
|
||||||
|
const confirmation = confirm("Are you sure you want to delete this whisper?");
|
||||||
|
if(confirmation) {
|
||||||
|
deleteWhisper(id)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Initialization ==
|
||||||
|
refreshAllUI()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
<li><a href="#">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<button type="button" class="block" id="whisper-create">Spread a whisper 🤭</button>
|
||||||
|
<div id="whispers"></div>
|
||||||
|
</main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,110 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
background-color: #343e8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li {
|
||||||
|
float: right;
|
||||||
|
margin-right: 4%;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li:last-child {
|
||||||
|
float: left;
|
||||||
|
margin-left: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whispers {
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
background: #f8f6f6;
|
||||||
|
border: 2px solid #343e8b;
|
||||||
|
margin: 1.5em 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
article p {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions > button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 3px;
|
||||||
|
border: none;
|
||||||
|
background-color: #f8f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
width: 60%;
|
||||||
|
border: none;
|
||||||
|
background-color: #343e8b;
|
||||||
|
padding: 0.5em 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import bodyParser from 'body-parser'
|
||||||
|
import { getAll, getById, create, updateById, deleteById } from './store.js'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(express.static('public'))
|
||||||
|
app.use(bodyParser.json())
|
||||||
|
app.set('view engine', 'ejs')
|
||||||
|
|
||||||
|
app.get('/about', async (req, res) => {
|
||||||
|
const whispers = await getAll()
|
||||||
|
res.render('about', { whispers })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/whisper', async (req, res) => {
|
||||||
|
const whispers = await getAll()
|
||||||
|
res.json(whispers)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/whisper/:id', async (req, res) => {
|
||||||
|
const id = req.params.id
|
||||||
|
const whisper = await getById(id)
|
||||||
|
if (!whisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
} else {
|
||||||
|
res.json(whisper)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/v1/whisper', async (req, res) => {
|
||||||
|
const { message } = req.body
|
||||||
|
if (!message) {
|
||||||
|
res.sendStatus(400)
|
||||||
|
} else {
|
||||||
|
const whisper = await create(message)
|
||||||
|
res.status(201).json(whisper)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/v1/whisper/:id', async (req, res) => {
|
||||||
|
const { message } = req.body
|
||||||
|
const id = req.params.id
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
res.sendStatus(400)
|
||||||
|
} else {
|
||||||
|
const whisper = await getById(id)
|
||||||
|
if (!whisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
} else {
|
||||||
|
await updateById(id, message)
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/v1/whisper/:id', async (req, res) => {
|
||||||
|
const id = req.params.id
|
||||||
|
const whisper = await getById(id)
|
||||||
|
|
||||||
|
if (!whisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteById(id)
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
export { app }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {
|
||||||
|
Whisper
|
||||||
|
} from './database.js'
|
||||||
|
|
||||||
|
const getAll = () => Whisper.find()
|
||||||
|
const getById = id => Whisper.findById({ _id: id })
|
||||||
|
const create = async (message) => {
|
||||||
|
const whisper = new Whisper({ message })
|
||||||
|
await whisper.save()
|
||||||
|
return whisper
|
||||||
|
}
|
||||||
|
const updateById = async (id, message) => Whisper.findOneAndUpdate({ _id: id }, { message }, { new: false })
|
||||||
|
const deleteById = async (id) => Whisper.deleteOne({ _id: id })
|
||||||
|
|
||||||
|
export { getAll, getById, create, updateById, deleteById }
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import supertest from 'supertest'
|
||||||
|
import { app } from '../server'
|
||||||
|
import { getById } from '../store.js'
|
||||||
|
import { restoreDb, populateDb, getFixtures, ensureDbConnection, normalize, closeDbConnection } from './utils.js'
|
||||||
|
|
||||||
|
let whispers
|
||||||
|
let inventedId
|
||||||
|
let existingId
|
||||||
|
|
||||||
|
describe('Server', () => {
|
||||||
|
beforeAll(ensureDbConnection)
|
||||||
|
beforeEach(async () => {
|
||||||
|
await restoreDb()
|
||||||
|
await populateDb(whispers)
|
||||||
|
const fixtures = await getFixtures()
|
||||||
|
whispers = fixtures.whispers
|
||||||
|
inventedId = fixtures.inventedId
|
||||||
|
existingId = fixtures.existingId
|
||||||
|
})
|
||||||
|
afterAll(closeDbConnection)
|
||||||
|
describe('GET /about', () => {
|
||||||
|
it('Should return a 200 with the total whispers in the platform', async () => {
|
||||||
|
const response = await supertest(app).get('/about')
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.text).toContain(`Currently there are ${whispers.length} whispers available`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('GET /api/v1/whisper', () => {
|
||||||
|
it("Should return an empty array when there's no data", async () => {
|
||||||
|
await restoreDb() // empty the db
|
||||||
|
const response = await supertest(app).get('/api/v1/whisper')
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual([])
|
||||||
|
})
|
||||||
|
it('Should return all the whispers', async () => {
|
||||||
|
const response = await supertest(app).get('/api/v1/whisper')
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual(whispers)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('GET /api/v1/whisper/:id', () => {
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app).get(`/api/v1/whisper/${inventedId}`)
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a whisper details', async () => {
|
||||||
|
const response = await supertest(app).get(`/api/v1/whisper/${existingId}`)
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual(whispers.find(w => w.id === existingId))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('POST /api/v1/whisper', () => {
|
||||||
|
it('Should return a 400 when the body is empty', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.send({})
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the body is invalid', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.send({ invented: 'This is a new whisper' })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 201 when the whisper is created', async () => {
|
||||||
|
const newWhisper = { message: 'This is a new whisper' }
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.send({ message: newWhisper.message })
|
||||||
|
expect(response.status).toBe(201)
|
||||||
|
expect(response.body.message).toEqual(newWhisper.message)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(response.body.id)
|
||||||
|
expect(normalize(storedWhisper).message).toStrictEqual(newWhisper.message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('PUT /api/v1/whisper/:id', () => {
|
||||||
|
it('Should return a 400 when the body is empty', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.send({})
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the body is invalid', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.send({ invented: 'This a new field' })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${inventedId}`)
|
||||||
|
.send({ message: 'Whisper updated' })
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a 200 when the whisper is updated', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.send({ message: 'Whisper updated' })
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(existingId)
|
||||||
|
expect(normalize(storedWhisper)).toStrictEqual({ id: existingId, message: 'Whisper updated' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('DELETE /api/v1/whisper/:id', () => {
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app).delete(`/api/v1/whisper/${inventedId}`)
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a 200 when the whisper is deleted', async () => {
|
||||||
|
const response = await supertest(app).delete(`/api/v1/whisper/${existingId}`)
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(existingId)
|
||||||
|
expect(storedWhisper).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
import {
|
||||||
|
Whisper
|
||||||
|
} from '../database.js'
|
||||||
|
|
||||||
|
const ensureDbConnection = async () => {
|
||||||
|
try {
|
||||||
|
if (mongoose.connection.readyState !== 1) {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error connecting to the database:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const closeDbConnection = async () => {
|
||||||
|
if (mongoose.connection.readyState === 1) {
|
||||||
|
await mongoose.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const restoreDb = () => Whisper.deleteMany({})
|
||||||
|
const populateDb = () => Whisper.insertMany([{ message: 'test' }, { message: 'hello world' }])
|
||||||
|
const getFixtures = async () => {
|
||||||
|
const data = await Whisper.find()
|
||||||
|
const whispers = JSON.parse(JSON.stringify(data))
|
||||||
|
const inventedId = '64e0e5c75a4a3c715b7c1074'
|
||||||
|
const existingId = data[0].id
|
||||||
|
return { inventedId, existingId, whispers }
|
||||||
|
}
|
||||||
|
const normalize = (data) => JSON.parse(JSON.stringify(data))
|
||||||
|
|
||||||
|
export { restoreDb, populateDb, getFixtures, ensureDbConnection, normalize, closeDbConnection }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>Welcome to Whispering!</h1>
|
||||||
|
<figure>
|
||||||
|
<img src="people.jpg" alt="three people sitting at the table laughing together" />
|
||||||
|
<figcaption>Photo by <a href="https://unsplash.com/photos/g1Kr4Ozfoac">Brooke Cagle</a> from <a
|
||||||
|
href="https://unsplash.com/">Unsplash</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h2>What is Whispering?</h2>
|
||||||
|
<p>Whispering is a microblogging platform that allows you to share your thoughts with the world and learn
|
||||||
|
Node.js on the way.</p>
|
||||||
|
|
||||||
|
<h2>Community live ⚡️</h2>
|
||||||
|
<p>Currently there are <%= whispers.length %> whispers available</p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "presets": ["@babel/preset-env"] }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20.11.0
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
import validator from 'validator'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { checkPasswordStrength } from './utils.js'
|
||||||
|
|
||||||
|
mongoose.set('toJSON', {
|
||||||
|
virtuals: true,
|
||||||
|
transform: (doc, converted) => {
|
||||||
|
delete converted._id
|
||||||
|
delete converted.__v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Schema definitions
|
||||||
|
const userSchema = new mongoose.Schema({
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
unique: true,
|
||||||
|
trim: true,
|
||||||
|
required: [true, 'Username is required'],
|
||||||
|
minlength: [3, 'Username must be at least 3 characters long'],
|
||||||
|
maxlength: [20, 'Username must be at most 20 characters long']
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
required: [true, 'Password is required'],
|
||||||
|
minlength: [8, 'Password must be at least 8 characters long'],
|
||||||
|
validate: {
|
||||||
|
validator: checkPasswordStrength
|
||||||
|
}
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
unique: true,
|
||||||
|
trim: true,
|
||||||
|
lowercase: true,
|
||||||
|
select: false,
|
||||||
|
required: [true, 'Email is required'],
|
||||||
|
validate: {
|
||||||
|
validator: validator.isEmail,
|
||||||
|
message: 'Email is not valid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const whisperSchema = new mongoose.Schema({
|
||||||
|
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
||||||
|
message: String,
|
||||||
|
updatedDate: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
},
|
||||||
|
creationDate: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
whisperSchema.pre('save', function (next) {
|
||||||
|
this.updatedDate = Date.now()
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
userSchema.pre('save', async function (next) {
|
||||||
|
const user = this
|
||||||
|
if (user.isModified('password')) {
|
||||||
|
const salt = await bcrypt.genSalt()
|
||||||
|
user.password = await bcrypt.hash(user.password, salt)
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
userSchema.methods.comparePassword = async function (candidatePassword) {
|
||||||
|
const user = this
|
||||||
|
return await bcrypt.compare(candidatePassword, user.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model definitions
|
||||||
|
const Whisper = mongoose.model('Whisper', whisperSchema)
|
||||||
|
const User = mongoose.model('User', userSchema)
|
||||||
|
|
||||||
|
export {
|
||||||
|
Whisper,
|
||||||
|
User
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
container_name: whispering-database
|
||||||
|
image: mongo:7.0
|
||||||
|
ports:
|
||||||
|
- '27017:27017'
|
||||||
|
volumes:
|
||||||
|
- db-storage:/data/db
|
||||||
|
volumes:
|
||||||
|
db-storage:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { app } from './server.js'
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const port = process.env.PORT
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI)
|
||||||
|
console.log('Connected to MongoDB')
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Running in http://localhost:${port}`)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/node_test/'],
|
||||||
|
coveragePathIgnorePatterns: [
|
||||||
|
'<rootDir>/tests/'
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+10695
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "nodejs-for-beginners",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --require dotenv/config index.js",
|
||||||
|
"test": "jest --setupFiles dotenv/config",
|
||||||
|
"test:coverage": "jest --coverage --setupFiles dotenv/config",
|
||||||
|
"lint": "standard",
|
||||||
|
"lint:fix": "standard --fix",
|
||||||
|
"infra:start": "docker-compose up -d --build",
|
||||||
|
"infra:stop": "docker-compose down --remove-orphans"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"standard": {
|
||||||
|
"env": [
|
||||||
|
"jest"
|
||||||
|
],
|
||||||
|
"ignore": [
|
||||||
|
"public/*.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"ejs": "^3.1.9",
|
||||||
|
"express": "^4.18.3",
|
||||||
|
"jsonwebtoken": "^9.0.1",
|
||||||
|
"mongoose": "7.4",
|
||||||
|
"validator": "^13.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-env": "^7.24.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"standard": "^17.1.0",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// == Selectors ==
|
||||||
|
const whispers = document.getElementById('whispers')
|
||||||
|
const whisperCreateButton = document.getElementById('whisper-create')
|
||||||
|
const welcome = document.getElementById('welcome')
|
||||||
|
// == Event Listeners ==
|
||||||
|
whispers.addEventListener('click', event => {
|
||||||
|
if(event.target.tagName === 'BUTTON') {
|
||||||
|
const button = event.target
|
||||||
|
const article = event.target.closest('article')
|
||||||
|
const action = button.dataset.action
|
||||||
|
const id = article.dataset.id
|
||||||
|
const message = article.querySelector('p').innerText
|
||||||
|
if(action === 'edit') requestUserEdit(id, message)
|
||||||
|
if(action === 'delete') requestUserDelete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
whisperCreateButton.addEventListener('click', event => {
|
||||||
|
const message = prompt("What's your whisper?")
|
||||||
|
if(message) {
|
||||||
|
createWhisper(message, user.id)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// === Functions ==
|
||||||
|
// -- Utils --
|
||||||
|
|
||||||
|
function parseJwt (token) {
|
||||||
|
var base64Url = token.split('.')[1];
|
||||||
|
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) {
|
||||||
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||||
|
}).join(''));
|
||||||
|
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- API --
|
||||||
|
const fetchAllWhispers = () => fetch('http://localhost:3000/api/v1/whisper', {
|
||||||
|
headers: {Authentication: `Bearer ${accessToken}`}
|
||||||
|
}).then((response) => response.json())
|
||||||
|
const deleteWhisper = (id) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {Authentication: `Bearer ${accessToken}`}
|
||||||
|
})
|
||||||
|
const updateWhisper = (id, message) => fetch(`http://localhost:3000/api/v1/whisper/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authentication': `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message }) })
|
||||||
|
const createWhisper = (message) => fetch('http://localhost:3000/api/v1/whisper', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authentication': `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message }) })
|
||||||
|
|
||||||
|
// -- UI --
|
||||||
|
const controlEdition = (whisper, user) => {
|
||||||
|
if(whisper.author.id === user.id) {
|
||||||
|
return ''
|
||||||
|
} else {
|
||||||
|
return 'style="display:none;"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshWhispers = data => whispers.innerHTML = data
|
||||||
|
.reverse()
|
||||||
|
.map(whisper => {
|
||||||
|
return `
|
||||||
|
<article data-id="${whisper.id}">
|
||||||
|
<div class="actions" ${controlEdition(whisper, user)}>
|
||||||
|
<button data-action="edit">✏️</button>
|
||||||
|
<button data-action="delete">❌</button>
|
||||||
|
</div>
|
||||||
|
<p>${whisper.message}</p>
|
||||||
|
</hr>
|
||||||
|
<p class="meta">
|
||||||
|
<span class="author">By ${whisper.author.username}</span>
|
||||||
|
<span class="date">at ${new Date(whisper.creationDate).toLocaleString()}</span>
|
||||||
|
</p>
|
||||||
|
</article>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const refreshAllUI = () => fetchAllWhispers().then(refreshWhispers)
|
||||||
|
|
||||||
|
const requestUserEdit = (id, message) => {
|
||||||
|
const newMessage = prompt("Edit the Whisper", message);
|
||||||
|
if(newMessage && newMessage !== message) {
|
||||||
|
updateWhisper(id, newMessage)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const requestUserDelete = (id) => {
|
||||||
|
const confirmation = confirm("Are you sure you want to delete this whisper?");
|
||||||
|
if(confirmation) {
|
||||||
|
deleteWhisper(id)
|
||||||
|
.then(refreshAllUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Initialization ==
|
||||||
|
const accessToken = localStorage.getItem('accessToken')
|
||||||
|
|
||||||
|
if(!accessToken) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data: user} = parseJwt(accessToken)
|
||||||
|
|
||||||
|
welcome.innerText = `Welcome, ${user.username} 👋`
|
||||||
|
refreshAllUI()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
const locationPath = window.location.pathname
|
||||||
|
let accessToken = localStorage.getItem('accessToken')
|
||||||
|
|
||||||
|
if(accessToken) {
|
||||||
|
localStorage.removeItem('accessToken')
|
||||||
|
accessToken = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if(locationPath === '/login'){
|
||||||
|
const login = document.getElementById('login');
|
||||||
|
login.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const username = event.target.username.value;
|
||||||
|
const password = event.target.password.value;
|
||||||
|
|
||||||
|
fetch('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if(response.status !== 200) {
|
||||||
|
throw new Error("Invalid credentials")
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
})
|
||||||
|
.then(({accessToken}) => {
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
window.location.href = '/';
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert(error);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if(locationPath === '/signup'){
|
||||||
|
const sigupForm = document.getElementById('sigup');
|
||||||
|
sigupForm.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const username = event.target.username.value;
|
||||||
|
const email = event.target.email.value;
|
||||||
|
const password = event.target.password.value;
|
||||||
|
|
||||||
|
fetch('/signup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if(response.status !== 200) {
|
||||||
|
throw new Error("Error while registering the user")
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
})
|
||||||
|
.then(({accessToken}) => {
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
window.location.href = '/';
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert(error);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
<li><a href="#">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<p id="welcome"></p>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
<button type="button" class="block" id="whisper-create">Spread a whisper 🤭</button>
|
||||||
|
<div id="whispers"></div>
|
||||||
|
</main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,133 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
background-color: #343e8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li {
|
||||||
|
float: right;
|
||||||
|
margin-right: 4%;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li:last-child {
|
||||||
|
float: left;
|
||||||
|
margin-left: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#whispers {
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
margin-top: 20px;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
background: #f8f6f6;
|
||||||
|
border: 2px solid #343e8b;
|
||||||
|
margin: 1.5em 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
article p {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .meta {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .actions > button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 3px;
|
||||||
|
border: none;
|
||||||
|
background-color: #f8f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
width: 60%;
|
||||||
|
border: none;
|
||||||
|
background-color: #343e8b;
|
||||||
|
padding: 0.5em 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
border: #343e8b 2px solid;
|
||||||
|
width: 350px;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form input {
|
||||||
|
margin: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 5px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import bodyParser from 'body-parser'
|
||||||
|
import * as whisper from './stores/whisper.js'
|
||||||
|
import * as user from './stores/user.js'
|
||||||
|
import { generateToken, requireAuthentication } from './utils.js'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(express.static('public'))
|
||||||
|
app.use(bodyParser.json())
|
||||||
|
app.set('view engine', 'ejs')
|
||||||
|
|
||||||
|
app.get('/login', (req, res) => {
|
||||||
|
res.render('login')
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/signup', (req, res) => {
|
||||||
|
res.render('signup')
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/logout', (req, res) => {
|
||||||
|
res.redirect('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body
|
||||||
|
const foundUser = await user.getUserByCredentials(username, password)
|
||||||
|
const accessToken = generateToken({ username, id: foundUser._id })
|
||||||
|
res.json({ accessToken })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.post('/signup', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password, email } = req.body
|
||||||
|
const newUser = await user.create(username, password, email)
|
||||||
|
const accessToken = generateToken({ username, id: newUser._id })
|
||||||
|
res.json({ accessToken })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/about', async (req, res) => {
|
||||||
|
const whispers = await whisper.getAll()
|
||||||
|
res.render('about', { whispers })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/whisper', requireAuthentication, async (req, res) => {
|
||||||
|
const whispers = await whisper.getAll()
|
||||||
|
res.json(whispers)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/v1/whisper/:id', requireAuthentication, async (req, res) => {
|
||||||
|
const id = req.params.id
|
||||||
|
const storedWhisper = await whisper.getById(id)
|
||||||
|
if (!storedWhisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
} else {
|
||||||
|
res.json(storedWhisper)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/v1/whisper', requireAuthentication, async (req, res) => {
|
||||||
|
const { message } = req.body
|
||||||
|
if (!message) {
|
||||||
|
res.sendStatus(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newWhisper = await whisper.create(message, req.user.id)
|
||||||
|
res.status(201).json(newWhisper)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/v1/whisper/:id', requireAuthentication, async (req, res) => {
|
||||||
|
const { message } = req.body
|
||||||
|
const id = req.params.id
|
||||||
|
if (!message) {
|
||||||
|
res.sendStatus(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const storedWhisper = await whisper.getById(id)
|
||||||
|
if (!storedWhisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedWhisper.author.id !== req.user.id) {
|
||||||
|
res.sendStatus(403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await whisper.updateById(id, message)
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/v1/whisper/:id', requireAuthentication, async (req, res) => {
|
||||||
|
const id = req.params.id
|
||||||
|
const storedWhisper = await whisper.getById(id)
|
||||||
|
if (!storedWhisper) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (storedWhisper.author.id !== req.user.id) {
|
||||||
|
res.sendStatus(403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await whisper.deleteById(id)
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
export { app }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
User
|
||||||
|
} from '../database.js'
|
||||||
|
|
||||||
|
const create = async (username, password, email) => {
|
||||||
|
const user = new User({ username, password, email })
|
||||||
|
await user.save()
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByCredentials = async (username, password) => {
|
||||||
|
const user = await User.findOne({ username })
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found')
|
||||||
|
}
|
||||||
|
const isMatch = await user.comparePassword(password)
|
||||||
|
if (!isMatch) {
|
||||||
|
throw new Error('Password is incorrect')
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export { create, getUserByCredentials }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {
|
||||||
|
Whisper
|
||||||
|
} from '../database.js'
|
||||||
|
|
||||||
|
const getAll = () => Whisper.find().populate('author', 'username')
|
||||||
|
const getById = id => Whisper.findById({ _id: id }).populate('author', 'username')
|
||||||
|
const create = async (message, authorId) => {
|
||||||
|
const whisper = new Whisper({ message, author: authorId })
|
||||||
|
await whisper.save()
|
||||||
|
return whisper
|
||||||
|
}
|
||||||
|
const updateById = async (id, message) => Whisper.findOneAndUpdate({ _id: id }, { message }, { new: false })
|
||||||
|
const deleteById = async (id) => Whisper.deleteOne({ _id: id })
|
||||||
|
|
||||||
|
export { getAll, getById, create, updateById, deleteById }
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import supertest from 'supertest'
|
||||||
|
import { app } from '../server'
|
||||||
|
import { getById } from '../stores/whisper'
|
||||||
|
import { restoreDb, populateDb, getFixtures, ensureDbConnection, normalize, closeDbConnection } from './utils.js'
|
||||||
|
|
||||||
|
let whispers
|
||||||
|
let inventedId
|
||||||
|
let existingId
|
||||||
|
let firstUser
|
||||||
|
let secondUser
|
||||||
|
|
||||||
|
describe('Server', () => {
|
||||||
|
beforeAll(ensureDbConnection)
|
||||||
|
beforeEach(async () => {
|
||||||
|
await restoreDb()
|
||||||
|
await populateDb(whispers)
|
||||||
|
const fixtures = await getFixtures()
|
||||||
|
whispers = fixtures.whispers
|
||||||
|
inventedId = fixtures.inventedId
|
||||||
|
existingId = fixtures.existingId
|
||||||
|
firstUser = fixtures.firstUser
|
||||||
|
secondUser = fixtures.secondUser
|
||||||
|
})
|
||||||
|
afterAll(closeDbConnection)
|
||||||
|
describe('GET /login', () => {
|
||||||
|
it('Should return a 200 with a login page', async () => {
|
||||||
|
const response = await supertest(app).get('/login')
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.text).toContain('Welcome Back!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('GET /signup', () => {
|
||||||
|
it('Should return a 200 with a signup page', async () => {
|
||||||
|
const response = await supertest(app).get('/signup')
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.text).toContain('Create your account!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('POST /signup', () => {
|
||||||
|
const newUser = {
|
||||||
|
username: 'jane_doe2',
|
||||||
|
password: '123456ASDasd@#',
|
||||||
|
email: 'jane_doe2@demo.foo'
|
||||||
|
}
|
||||||
|
it('Should return a 400 when the body is empty', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/signup')
|
||||||
|
.send({})
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the body is not completed', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/signup')
|
||||||
|
.send({ username: newUser.username })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(response.body.error).toBe('User validation failed: password: Password is required, email: Email is required')
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the password is weak', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/signup')
|
||||||
|
.send({ ...newUser, password: 'weak' })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(response.body.error).toBe('User validation failed: password: Password must be at least 8 characters long')
|
||||||
|
})
|
||||||
|
it('Should return a 200 and a token when the user is created', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/signup')
|
||||||
|
.send(newUser)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body.accessToken).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('POST /login', () => {
|
||||||
|
it('Should return a 400 when the body is empty', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/login')
|
||||||
|
.send({})
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the body is not completed', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/login')
|
||||||
|
.send({ username: 'jane_doe' })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the user is not found', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/login')
|
||||||
|
.send({ username: `${firstUser.username}_invented`, password: firstUser.password })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the password is incorrect', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/login')
|
||||||
|
.send({ username: firstUser.username, password: `${firstUser.password}_invented` })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 200 and an accessToken when the user is created', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/login')
|
||||||
|
.send({ username: firstUser.username, password: firstUser.password })
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body.accessToken).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('GET /about', () => {
|
||||||
|
it('Should return a 200 with the total whispers in the platform', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/about')
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.text).toContain(`Currently there are ${whispers.length} whispers available`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('GET /api/v1/whisper', () => {
|
||||||
|
it('Should return a 401 when the user is not authenticated', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/api/v1/whisper')
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
expect(response.body.error).toBe('No token provided')
|
||||||
|
})
|
||||||
|
it("Should return an empty array when there's no data", async () => {
|
||||||
|
await restoreDb() // empty the db
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/api/v1/whisper')
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual([])
|
||||||
|
})
|
||||||
|
it('Should return all the whispers', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get('/api/v1/whisper')
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual(whispers)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('GET /api/v1/whisper/:id', () => {
|
||||||
|
it('Should return a 401 when the user is not authenticated', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get(`/api/v1/whisper/${existingId}`)
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
expect(response.body.error).toBe('No token provided')
|
||||||
|
})
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get(`/api/v1/whisper/${inventedId}`)
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a whisper details', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.get(`/api/v1/whisper/${existingId}`)
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body).toEqual(whispers.find(w => w.id === existingId))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('POST /api/v1/whisper', () => {
|
||||||
|
it('Should return a 400 when the body is empty', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
.send({})
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the body is invalid', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
.send({ invented: 'This is a new whisper' })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 401 when the user is not authenticated', async () => {
|
||||||
|
const newWhisper = { message: 'This is a new whisper' }
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.send({ message: newWhisper.message })
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
expect(response.body.error).toBe('No token provided')
|
||||||
|
})
|
||||||
|
it('Should return a 201 when the whisper is created', async () => {
|
||||||
|
const newWhisper = { message: 'This is a new whisper' }
|
||||||
|
const response = await supertest(app)
|
||||||
|
.post('/api/v1/whisper')
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
.send({ message: newWhisper.message })
|
||||||
|
expect(response.status).toBe(201)
|
||||||
|
expect(response.body.message).toEqual(newWhisper.message)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(response.body.id)
|
||||||
|
expect(normalize(storedWhisper).message).toStrictEqual(newWhisper.message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('PUT /api/v1/whisper/:id', () => {
|
||||||
|
it('Should return a 400 when the body is empty', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
.send({})
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it('Should return a 400 when the body is invalid', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
.send({ invented: 'This a new field' })
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${inventedId}`)
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
.send({ message: 'Whisper updated' })
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a 401 when the user is not authenticated', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.send({ message: 'Whisper updated' })
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
expect(response.body.error).toBe('No token provided')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should return a 403 when the user is not the author', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.set('Authentication', `Bearer ${secondUser.token}`)
|
||||||
|
.send({ message: 'Whisper updated' })
|
||||||
|
|
||||||
|
expect(response.status).toBe(403)
|
||||||
|
})
|
||||||
|
it('Should return a 200 when the whisper is updated', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.put(`/api/v1/whisper/${existingId}`)
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
.send({ message: 'Whisper updated' })
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(existingId)
|
||||||
|
const normalizedWhisper = normalize(storedWhisper)
|
||||||
|
expect(normalizedWhisper.id).toBe(existingId)
|
||||||
|
expect(normalizedWhisper.message).toBe('Whisper updated')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('DELETE /api/v1/whisper/:id', () => {
|
||||||
|
it("Should return a 404 when the whisper doesn't exist", async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.delete(`/api/v1/whisper/${inventedId}`)
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
})
|
||||||
|
it('Should return a 401 when the user is not authenticated', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.delete(`/api/v1/whisper/${existingId}`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
expect(response.body.error).toBe('No token provided')
|
||||||
|
})
|
||||||
|
it('Should return a 403 when the user is not the author', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.delete(`/api/v1/whisper/${existingId}`)
|
||||||
|
.set('Authentication', `Bearer ${secondUser.token}`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should return a 200 when the whisper is deleted', async () => {
|
||||||
|
const response = await supertest(app)
|
||||||
|
.delete(`/api/v1/whisper/${existingId}`)
|
||||||
|
.set('Authentication', `Bearer ${firstUser.token}`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
|
||||||
|
// Database changes
|
||||||
|
const storedWhisper = await getById(existingId)
|
||||||
|
expect(storedWhisper).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
import {
|
||||||
|
Whisper,
|
||||||
|
User
|
||||||
|
} from '../database.js'
|
||||||
|
import { generateToken } from '../utils.js'
|
||||||
|
|
||||||
|
const ensureDbConnection = async () => {
|
||||||
|
try {
|
||||||
|
if (mongoose.connection.readyState !== 1) {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error connecting to the database:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const closeDbConnection = async () => {
|
||||||
|
if (mongoose.connection.readyState === 1) {
|
||||||
|
await mongoose.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const restoreDb = async () => {
|
||||||
|
await Whisper.deleteMany({})
|
||||||
|
await User.deleteMany({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUsersFixtures = () => [
|
||||||
|
{ username: 'jane_doe', password: 'qg82H0Zt1Ee6F2ESNwI!ZN8iq7N', email: 'jane@doe.com' },
|
||||||
|
{ username: 'joe_doe', password: 'nnO864BTxe#103Hl8eI!Qx#0xCw', email: 'joe@doe.com' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const populateDb = async () => {
|
||||||
|
const users = []
|
||||||
|
for (const user of getUsersFixtures()) {
|
||||||
|
const storedUser = await User.create(user)
|
||||||
|
users.push(storedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{ message: 'Jane testing', author: users[0]._id },
|
||||||
|
{ message: 'hello world from Joe', author: users[0]._id }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
await Whisper.create(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const getFixtures = async () => {
|
||||||
|
const data = await Whisper.find().populate('author', 'username')
|
||||||
|
const whispers = JSON.parse(JSON.stringify(data))
|
||||||
|
const inventedId = '64e0e5c75a4a3c715b7c1074'
|
||||||
|
const existingId = data[0].id
|
||||||
|
const storedUsers = await User.find({})
|
||||||
|
const [firstUser, secondUser] = getUsersFixtures()
|
||||||
|
firstUser.id = storedUsers[0]._id.toString()
|
||||||
|
secondUser.id = storedUsers[1]._id.toString()
|
||||||
|
firstUser.token = generateToken({ id: firstUser.id, username: firstUser.username })
|
||||||
|
secondUser.token = generateToken({ id: secondUser.id, username: secondUser.username })
|
||||||
|
return { inventedId, existingId, whispers, firstUser, secondUser }
|
||||||
|
}
|
||||||
|
const normalize = (data) => JSON.parse(JSON.stringify(data))
|
||||||
|
|
||||||
|
export { restoreDb, populateDb, getFixtures, ensureDbConnection, normalize, closeDbConnection }
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
|
export function checkPasswordStrength (password) {
|
||||||
|
// Minimum eight characters, at least one letter, one number and one special character:
|
||||||
|
const strengthRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/
|
||||||
|
return strengthRegex.test(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateToken (data) {
|
||||||
|
return jwt.sign({
|
||||||
|
data
|
||||||
|
}, process.env.JWT_SECRET, { expiresIn: '1h' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuthentication (req, res, next) {
|
||||||
|
const token = req.headers.authentication
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: 'No token provided' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const accessToken = token.split(' ')[1]
|
||||||
|
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET)
|
||||||
|
req.user = decoded.data
|
||||||
|
next()
|
||||||
|
} catch (err) {
|
||||||
|
res.status(401).json({ error: 'Invalid token' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Home</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>Welcome to Whispering!</h1>
|
||||||
|
<figure>
|
||||||
|
<img src="people.jpg" alt="three people sitting at the table laughing together" />
|
||||||
|
<figcaption>Photo by <a href="https://unsplash.com/photos/g1Kr4Ozfoac">Brooke Cagle</a> from <a
|
||||||
|
href="https://unsplash.com/">Unsplash</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h2>What is Whispering?</h2>
|
||||||
|
<p>Whispering is a microblogging platform that allows you to share your thoughts with the world and learn
|
||||||
|
Node.js on the way.</p>
|
||||||
|
|
||||||
|
<h2>Community live ⚡️</h2>
|
||||||
|
<p>Currently there are <%= whispers.length %> whispers available</p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | Login</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>Welcome Back!</h1>
|
||||||
|
|
||||||
|
<form id="login">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Your username" required />
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Your password" required />
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>Don't have an account yet? <a href="/signup">Sign Up</a></p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<script src="auth.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Whispering | register</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="menu">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Whispering</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>Create your account!</h1>
|
||||||
|
|
||||||
|
<form id="sigup">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Your username" required />
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="Your email" required />
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Your password" required />
|
||||||
|
<button type="submit">Sign up</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>Already have an account? <a href="/login">Login</a></p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<script src="auth.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "presets": ["@babel/preset-env"] }
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
name: Continous Integration
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
- name: Check code style
|
||||||
|
run: npm run lint
|
||||||
|
- name: Generate a random JWT secret
|
||||||
|
id: generate-secret
|
||||||
|
run: echo "::set-output name=JWT_SECRET::$(openssl rand -base64 30)"
|
||||||
|
shell: bash
|
||||||
|
- name: Prepare environment
|
||||||
|
run: npm run infra:start
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
env:
|
||||||
|
MONGODB_URI: mongodb://localhost:27017/whispering-database
|
||||||
|
PORT: 3000
|
||||||
|
SALT_ROUNDS: 10
|
||||||
|
JWT_SECRET: ${{ steps.generate-secret.outputs.JWT_SECRET }}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/windows,macos,linux,node
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,macos,linux,node
|
||||||
|
|
||||||
|
### Linux ###
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# KDE directory preferences
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
### macOS ###
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
### macOS Patch ###
|
||||||
|
# iCloud generated files
|
||||||
|
*.icloud
|
||||||
|
|
||||||
|
### Node ###
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
### Node Patch ###
|
||||||
|
# Serverless Webpack directories
|
||||||
|
.webpack/
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
# SvelteKit build / generate output
|
||||||
|
.svelte-kit
|
||||||
|
|
||||||
|
### Windows ###
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux,node
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20.11.0
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
import validator from 'validator'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { checkPasswordStrength } from './utils.js'
|
||||||
|
|
||||||
|
mongoose.set('toJSON', {
|
||||||
|
virtuals: true,
|
||||||
|
transform: (doc, converted) => {
|
||||||
|
delete converted._id
|
||||||
|
delete converted.__v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Schema definitions
|
||||||
|
const userSchema = new mongoose.Schema({
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
unique: true,
|
||||||
|
trim: true,
|
||||||
|
required: [true, 'Username is required'],
|
||||||
|
minlength: [3, 'Username must be at least 3 characters long'],
|
||||||
|
maxlength: [20, 'Username must be at most 20 characters long']
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
required: [true, 'Password is required'],
|
||||||
|
minlength: [8, 'Password must be at least 8 characters long'],
|
||||||
|
validate: {
|
||||||
|
validator: checkPasswordStrength
|
||||||
|
}
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
unique: true,
|
||||||
|
trim: true,
|
||||||
|
lowercase: true,
|
||||||
|
select: false,
|
||||||
|
required: [true, 'Email is required'],
|
||||||
|
validate: {
|
||||||
|
validator: validator.isEmail,
|
||||||
|
message: 'Email is not valid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const whisperSchema = new mongoose.Schema({
|
||||||
|
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
||||||
|
message: String,
|
||||||
|
updatedDate: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
},
|
||||||
|
creationDate: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
whisperSchema.pre('save', function (next) {
|
||||||
|
this.updatedDate = Date.now()
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
userSchema.pre('save', async function (next) {
|
||||||
|
const user = this
|
||||||
|
if (user.isModified('password')) {
|
||||||
|
const salt = await bcrypt.genSalt()
|
||||||
|
user.password = await bcrypt.hash(user.password, salt)
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
userSchema.methods.comparePassword = async function (candidatePassword) {
|
||||||
|
const user = this
|
||||||
|
return await bcrypt.compare(candidatePassword, user.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model definitions
|
||||||
|
const Whisper = mongoose.model('Whisper', whisperSchema)
|
||||||
|
const User = mongoose.model('User', userSchema)
|
||||||
|
|
||||||
|
export {
|
||||||
|
Whisper,
|
||||||
|
User
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
container_name: whispering-database
|
||||||
|
image: mongo:7.0
|
||||||
|
ports:
|
||||||
|
- '27017:27017'
|
||||||
|
volumes:
|
||||||
|
- db-storage:/data/db
|
||||||
|
volumes:
|
||||||
|
db-storage:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { app } from './server.js'
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const port = process.env.PORT
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI)
|
||||||
|
console.log('Connected to MongoDB')
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Running in http://localhost:${port}`)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user