├── .gitignore ├── src ├── routes.js ├── db.js ├── middleware │ └── auth.js ├── routes │ ├── things.js │ ├── users.js │ ├── auth.js │ └── my-things.js └── services │ └── auth.js ├── .env.example ├── .vscode └── settings.json ├── package.json ├── server.js ├── test ├── init-db.js ├── middleware.test.js ├── routes │ ├── things.test.js │ ├── users.test.js │ ├── auth.test.js │ └── my-things.test.js └── auth.test.js ├── README.md └── docs ├── DB.md └── API.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | !.vscode/settings.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # env 28 | .env -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | import authRoutes from "./routes/auth.js"; 4 | import thingsRoutes from "./routes/things.js"; 5 | import usersRoutes from "./routes/users.js"; 6 | import myThingsRoutes from "./routes/my-things.js"; 7 | 8 | const router = express.Router(); 9 | 10 | router.use("/auth", authRoutes); 11 | router.use("/things", thingsRoutes); 12 | router.use("/users", usersRoutes); 13 | router.use("/my-things", myThingsRoutes); 14 | 15 | export default router; 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # PORT 2 | PORT=3001 3 | 4 | # JWT Secrets 5 | ACCESS_TOKEN_SECRET="123" 6 | REFRESH_TOKEN_SECRET="456" 7 | 8 | # DB 9 | DB_USER="" 10 | DB_PW="" 11 | DB_NAME="" 12 | DB_HOST="localhost" 13 | DB_PORT=5432 14 | 15 | # Test DB 16 | TEST_DB_USER="" 17 | TEST_DB_PW="" 18 | TEST_DB_NAME="" 19 | TEST_DB_HOST="localhost" 20 | TEST_DB_PORT=5432 21 | 22 | # React app 23 | REACT_APP_DOMAIN="https://YOURAPPNAMEHERE.netlify.app" 24 | 25 | # Change this to production for the deployed app, or test for tests 26 | NODE_ENV=development 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[json]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[html]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[css]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[jsonc]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | }, 18 | "[javascriptreact]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | } 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "project-react-backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "server.js", 7 | "scripts": { 8 | "test": "NODE_ENV=test mocha --recursive", 9 | "start": "nodemon server.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "bcryptjs": "^2.4.3", 16 | "cookie-parser": "^1.4.6", 17 | "cors": "^2.8.5", 18 | "dotenv": "^16.4.5", 19 | "express": "^4.19.2", 20 | "jsonwebtoken": "^9.0.2", 21 | "pg": "^8.12.0" 22 | }, 23 | "devDependencies": { 24 | "chai": "^5.1.1", 25 | "mocha": "^10.4.0", 26 | "nodemon": "^3.1.3", 27 | "sinon": "^18.0.0", 28 | "supertest": "^7.0.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | import pg from "pg"; 2 | import dotenv from "dotenv"; 3 | 4 | const { Pool } = pg; 5 | 6 | dotenv.config(); 7 | 8 | const isTestEnv = process.env.NODE_ENV === "test"; 9 | 10 | // Create a new pool instance for managing database connections 11 | // Use different environment variables depending on whether it's a test environment 12 | const pool = new Pool({ 13 | user: isTestEnv ? process.env.TEST_DB_USER : process.env.DB_USER, 14 | host: isTestEnv ? process.env.TEST_DB_HOST : process.env.DB_HOST, 15 | database: isTestEnv ? process.env.TEST_DB_NAME : process.env.DB_NAME, 16 | password: isTestEnv ? process.env.TEST_DB_PW : process.env.DB_PW, 17 | port: isTestEnv ? process.env.TEST_DB_PORT : process.env.DB_PORT, 18 | }); 19 | 20 | export default pool; 21 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import dotenv from "dotenv"; 3 | import cors from "cors"; 4 | import cookieParser from "cookie-parser"; 5 | 6 | import routes from "./src/routes.js"; 7 | 8 | dotenv.config(); 9 | 10 | const app = express(); 11 | const port = process.env.PORT || 3001; 12 | 13 | // Define CORS options to allow requests from the specified origin and include credentials 14 | // This is crucial when using HTTP cookies for authentication, as cookies are not shared across domains by default 15 | // Includes credentials (such as cookies) in requests and responses 16 | const corsOptions = { 17 | origin: process.env.REACT_APP_DOMAIN, 18 | credentials: true, 19 | }; 20 | 21 | app.use(cors(corsOptions)); 22 | app.use(express.json()); 23 | app.use(cookieParser()); 24 | 25 | app.use("/api", routes); 26 | 27 | app.listen(port, () => { 28 | console.log(`Server running on port ${port}`); 29 | }); 30 | -------------------------------------------------------------------------------- /src/middleware/auth.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import dotenv from "dotenv"; 3 | 4 | // Load environment variables from a .env file 5 | dotenv.config(); 6 | 7 | // Middleware function to authenticate a token 8 | const authenticateToken = (req, res, next) => { 9 | // Get the authorization header from the request 10 | const authHeader = req.headers["authorization"]; 11 | 12 | // Extract the token from the header, if it exists 13 | const token = authHeader && authHeader.split(" ")[1]; 14 | 15 | // If no token is found, send a 401 (Unauthorized) response 16 | if (!token) return res.sendStatus(401); 17 | 18 | // Verify the token using the secret key 19 | jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => { 20 | // If verification fails, send a 403 (Forbidden) response 21 | if (err) return res.sendStatus(403); 22 | 23 | // If verification is successful, attach the user object to the request 24 | req.user = user; 25 | 26 | // Call the next middleware or route handler 27 | next(); 28 | }); 29 | }; 30 | 31 | export default authenticateToken; 32 | -------------------------------------------------------------------------------- /src/routes/things.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import pool from "../db.js"; 3 | 4 | const router = express.Router(); 5 | 6 | router.get("/", async (req, res) => { 7 | try { 8 | const result = await pool.query("SELECT * FROM things"); 9 | res.json(result.rows); 10 | } catch (error) { 11 | res.status(500).json({ error: "Internal Server Error" }); 12 | } 13 | }); 14 | 15 | router.get("/:id", async (req, res) => { 16 | const { id } = req.params; 17 | try { 18 | const result = await pool.query( 19 | ` 20 | SELECT 21 | things.*, 22 | users.name AS user_name 23 | FROM 24 | things 25 | JOIN 26 | users 27 | ON 28 | things.user_id = users.id 29 | WHERE 30 | things.id = $1 31 | `, 32 | [id] 33 | ); 34 | if (result.rows.length === 0) { 35 | return res.status(404).json({ error: "Thing not found" }); 36 | } 37 | res.json(result.rows[0]); 38 | } catch (error) { 39 | res.status(500).json({ error: "Internal Server Error" }); 40 | } 41 | }); 42 | 43 | export default router; 44 | -------------------------------------------------------------------------------- /test/init-db.js: -------------------------------------------------------------------------------- 1 | import pg from "pg"; 2 | import dotenv from "dotenv"; 3 | 4 | dotenv.config(); 5 | 6 | const { Pool } = pg; 7 | 8 | const pool = new Pool({ 9 | user: process.env.TEST_DB_USER, 10 | host: process.env.TEST_DB_HOST, 11 | database: process.env.TEST_DB_NAME, 12 | password: process.env.TEST_DB_PW, 13 | port: process.env.TEST_DB_PORT, 14 | }); 15 | 16 | const createTables = async () => { 17 | await pool.query(` 18 | CREATE TABLE IF NOT EXISTS users ( 19 | id SERIAL PRIMARY KEY, 20 | name VARCHAR(100), 21 | email VARCHAR(100) UNIQUE, 22 | password VARCHAR(100), 23 | bio TEXT 24 | ); 25 | `); 26 | await pool.query(` 27 | CREATE TABLE IF NOT EXISTS things ( 28 | id SERIAL PRIMARY KEY, 29 | name VARCHAR(100), 30 | description TEXT, 31 | user_id INTEGER REFERENCES users(id) 32 | ); 33 | `); 34 | }; 35 | 36 | const dropTables = async () => { 37 | await pool.query("DROP TABLE IF EXISTS things;"); 38 | await pool.query("DROP TABLE IF EXISTS users;"); 39 | }; 40 | 41 | export const initTestDb = async () => { 42 | await dropTables(); 43 | await createTables(); 44 | }; 45 | 46 | export const closePool = async () => { 47 | await pool.end(); 48 | }; 49 | -------------------------------------------------------------------------------- /src/routes/users.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import pool from "../db.js"; 3 | import authenticateToken from "../middleware/auth.js"; 4 | 5 | const router = express.Router(); 6 | 7 | router.get("/", authenticateToken, async (req, res) => { 8 | try { 9 | const result = await pool.query("SELECT * FROM users"); 10 | res.json(result.rows); 11 | } catch (error) { 12 | res.status(500).json({ error: "Internal Server Error" }); 13 | } 14 | }); 15 | 16 | router.get("/:id", authenticateToken, async (req, res) => { 17 | const { id } = req.params; 18 | try { 19 | const result = await pool.query("SELECT * FROM users WHERE id = $1", [id]); 20 | if (result.rows.length === 0) { 21 | return res.status(404).json({ error: "User not found" }); 22 | } 23 | res.json(result.rows[0]); 24 | } catch (error) { 25 | res.status(500).json({ error: "Internal Server Error" }); 26 | } 27 | }); 28 | 29 | router.get("/:id/things", authenticateToken, async (req, res) => { 30 | const { id } = req.params; 31 | try { 32 | const result = await pool.query("SELECT * FROM things WHERE user_id = $1", [ 33 | id, 34 | ]); 35 | res.json(result.rows); 36 | } catch (error) { 37 | res.status(500).json({ error: "Internal Server Error" }); 38 | } 39 | }); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /src/routes/auth.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { 3 | registerUser, 4 | loginUser, 5 | refreshAccessToken, 6 | } from "../services/auth.js"; 7 | 8 | const router = express.Router(); 9 | 10 | router.post("/register", async (req, res) => { 11 | try { 12 | const { name, email, password, bio } = req.body; 13 | const user = await registerUser(name, email, password, bio); 14 | res.status(201).json(user); 15 | } catch (error) { 16 | res.status(400).json({ error: error.message }); 17 | } 18 | }); 19 | 20 | router.post("/login", async (req, res) => { 21 | try { 22 | const { email, password } = req.body; 23 | const { id, name, accessToken, refreshToken } = await loginUser( 24 | email, 25 | password 26 | ); 27 | 28 | // Set the refresh token as an HttpOnly cookie 29 | res.cookie("refreshToken", refreshToken, { 30 | httpOnly: true, 31 | secure: process.env.NODE_ENV === "production", 32 | sameSite: "Strict", 33 | maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days 34 | }); 35 | 36 | // Send the access token with the user data in the response 37 | res.json({ id, name, accessToken }); 38 | } catch (error) { 39 | res.status(400).json({ error: error.message }); 40 | } 41 | }); 42 | 43 | router.post("/refresh-token", async (req, res) => { 44 | const refreshToken = req.cookies.refreshToken; 45 | if (!refreshToken) { 46 | return res.sendStatus(401); 47 | } 48 | 49 | try { 50 | const newAccessToken = await refreshAccessToken(refreshToken); 51 | res.json({ accessToken: newAccessToken }); 52 | } catch (error) { 53 | res.status(403).json({ error: error.message }); 54 | } 55 | }); 56 | 57 | router.post("/logout", (req, res) => { 58 | res.clearCookie("refreshToken"); 59 | res.status(200).json({ message: "Logged out" }); 60 | }); 61 | 62 | export default router; 63 | -------------------------------------------------------------------------------- /test/middleware.test.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import request from "supertest"; 3 | import { expect } from "chai"; 4 | import sinon from "sinon"; 5 | import jwt from "jsonwebtoken"; 6 | import authenticateToken from "../src/middleware/auth.js"; 7 | import dotenv from "dotenv"; 8 | 9 | dotenv.config(); 10 | 11 | describe("authenticateToken middleware", () => { 12 | let app; 13 | let jwtVerifyStub; 14 | 15 | beforeEach(() => { 16 | app = express(); 17 | app.use(express.json()); 18 | 19 | // A simple route to test the middleware 20 | app.get("/protected", authenticateToken, (req, res) => { 21 | res.status(200).json({ message: "Protected content", user: req.user }); 22 | }); 23 | 24 | // Stub jwt.verify 25 | jwtVerifyStub = sinon.stub(jwt, "verify"); 26 | }); 27 | 28 | afterEach(() => { 29 | sinon.restore(); 30 | }); 31 | 32 | it("should return 401 if no token is provided", async () => { 33 | const res = await request(app).get("/protected"); 34 | expect(res.status).to.equal(401); 35 | }); 36 | 37 | it("should return 403 if token is invalid", async () => { 38 | jwtVerifyStub.callsFake((token, secret, callback) => { 39 | callback(new Error("Invalid token"), null); 40 | }); 41 | 42 | const res = await request(app) 43 | .get("/protected") 44 | .set("Authorization", "Bearer invalidtoken"); 45 | 46 | expect(res.status).to.equal(403); 47 | }); 48 | 49 | it("should call next() and set req.user if token is valid", async () => { 50 | const mockUser = { id: 1, name: "Test User" }; 51 | jwtVerifyStub.callsFake((token, secret, callback) => { 52 | callback(null, mockUser); 53 | }); 54 | 55 | const res = await request(app) 56 | .get("/protected") 57 | .set("Authorization", "Bearer validtoken"); 58 | 59 | expect(res.status).to.equal(200); 60 | expect(res.body).to.deep.equal({ 61 | message: "Protected content", 62 | user: mockUser, 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/routes/my-things.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import pool from "../db.js"; 3 | import authenticateToken from "../middleware/auth.js"; 4 | 5 | const router = express.Router(); 6 | 7 | router.get("/", authenticateToken, async (req, res) => { 8 | const userId = req.user.id; 9 | try { 10 | const result = await pool.query("SELECT * FROM things WHERE user_id = $1", [ 11 | userId, 12 | ]); 13 | res.json(result.rows); 14 | } catch (error) { 15 | res.status(500).json({ error: "Internal Server Error" }); 16 | } 17 | }); 18 | 19 | router.post("/", authenticateToken, async (req, res) => { 20 | const { name, description } = req.body; 21 | const userId = req.user.id; 22 | try { 23 | const result = await pool.query( 24 | "INSERT INTO things (name, description, user_id) VALUES ($1, $2, $3) RETURNING *", 25 | [name, description, userId] 26 | ); 27 | res.status(201).json(result.rows[0]); 28 | } catch (error) { 29 | res.status(500).json({ error: "Internal Server Error" }); 30 | } 31 | }); 32 | 33 | router.put("/:id", authenticateToken, async (req, res) => { 34 | const { id } = req.params; 35 | const { name, description } = req.body; 36 | const userId = req.user.id; 37 | try { 38 | const result = await pool.query( 39 | "UPDATE things SET name = $1, description = $2 WHERE id = $3 AND user_id = $4 RETURNING *", 40 | [name, description, id, userId] 41 | ); 42 | if (result.rows.length === 0) { 43 | return res.status(404).json({ error: "Thing not found" }); 44 | } 45 | res.json(result.rows[0]); 46 | } catch (error) { 47 | res.status(500).json({ error: "Internal Server Error" }); 48 | } 49 | }); 50 | 51 | router.delete("/:id", authenticateToken, async (req, res) => { 52 | const { id } = req.params; 53 | const userId = req.user.id; 54 | try { 55 | const result = await pool.query( 56 | "DELETE FROM things WHERE id = $1 AND user_id = $2 RETURNING *", 57 | [id, userId] 58 | ); 59 | if (result.rows.length === 0) { 60 | return res.status(404).json({ error: "Thing not found" }); 61 | } 62 | res.sendStatus(204); 63 | } catch (error) { 64 | res.status(500).json({ error: "Internal Server Error" }); 65 | } 66 | }); 67 | 68 | export default router; 69 | -------------------------------------------------------------------------------- /test/routes/things.test.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import request from "supertest"; 3 | import { expect } from "chai"; 4 | import sinon from "sinon"; 5 | import thingsRouter from "../../src/routes/things.js"; 6 | import pool from "../../src/db.js"; 7 | import { initTestDb } from "../init-db.js"; 8 | import dotenv from "dotenv"; 9 | 10 | dotenv.config(); 11 | 12 | describe("Things API", () => { 13 | let app; 14 | 15 | before(async () => { 16 | await initTestDb(); 17 | }); 18 | 19 | beforeEach(async () => { 20 | app = express(); 21 | app.use(express.json()); 22 | 23 | // Reset the test database before each test 24 | await pool.query("TRUNCATE things, users RESTART IDENTITY CASCADE"); 25 | 26 | // Insert mock users 27 | await pool.query(` 28 | INSERT INTO users (name, email, password, bio) 29 | VALUES 30 | ('John Doe', 'johndoe@example.com', 'password123', 'Loves collecting things.'), 31 | ('Jane Doe', 'janedoe@example.com', 'password123', 'Loves collecting other things.'); 32 | `); 33 | 34 | // Insert mock things 35 | const things = await pool.query(` 36 | INSERT INTO things (name, description, user_id) 37 | VALUES 38 | ('Thing 1', 'Description 1', (SELECT id FROM users WHERE email = 'johndoe@example.com')), 39 | ('Thing 2', 'Description 2', (SELECT id FROM users WHERE email = 'johndoe@example.com')), 40 | ('Thing 3', 'Description 3', (SELECT id FROM users WHERE email = 'janedoe@example.com')) 41 | RETURNING *; 42 | `); 43 | 44 | app.use("/api/things", thingsRouter); 45 | }); 46 | 47 | afterEach(() => { 48 | sinon.restore(); 49 | }); 50 | 51 | describe("GET /api/things", () => { 52 | it("should return all things", async () => { 53 | const res = await request(app).get("/api/things"); 54 | expect(res.status).to.equal(200); 55 | expect(res.body).to.have.lengthOf(3); 56 | }); 57 | }); 58 | 59 | describe("GET /api/things/:id", () => { 60 | it("should return a specific thing", async () => { 61 | const res = await request(app).get("/api/things/1"); 62 | expect(res.status).to.equal(200); 63 | expect(res.body).to.include({ id: 1, name: "Thing 1" }); 64 | }); 65 | 66 | it("should return 404 if thing not found", async () => { 67 | const res = await request(app).get("/api/things/999"); 68 | expect(res.status).to.equal(404); 69 | expect(res.body).to.deep.equal({ error: "Thing not found" }); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/services/auth.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import bcrypt from "bcryptjs"; 3 | import dotenv from "dotenv"; 4 | import pool from "../db.js"; 5 | 6 | dotenv.config(); 7 | 8 | // Function to generate an access token that expires in 15 mins 9 | const generateAccessToken = (user) => { 10 | return jwt.sign(user, process.env.ACCESS_TOKEN_SECRET, { expiresIn: "15m" }); 11 | }; 12 | 13 | // Function to generate a refresh token that expires in 7 days 14 | const generateRefreshToken = (user) => { 15 | return jwt.sign(user, process.env.REFRESH_TOKEN_SECRET, { expiresIn: "7d" }); 16 | }; 17 | 18 | const registerUser = async (name, email, password, bio) => { 19 | // Check if the email already exists 20 | const emailCheckResult = await pool.query( 21 | "SELECT * FROM users WHERE email = $1", 22 | [email] 23 | ); 24 | if (emailCheckResult.rows.length > 0) { 25 | throw new Error("Email already exists"); 26 | } 27 | 28 | // If the email does not exist, proceed with registration 29 | // hash the password and insert the new user into the database 30 | const hashedPassword = await bcrypt.hash(password, 10); 31 | const result = await pool.query( 32 | "INSERT INTO users (name, email, password, bio) VALUES ($1, $2, $3, $4) RETURNING *", 33 | [name, email, hashedPassword, bio] 34 | ); 35 | 36 | // Return the newly created user 37 | return result.rows[0]; 38 | }; 39 | 40 | const loginUser = async (email, password) => { 41 | // Query the database for the user with the provided email 42 | const result = await pool.query("SELECT * FROM users WHERE email = $1", [ 43 | email, 44 | ]); 45 | 46 | const user = result.rows[0]; 47 | 48 | // If the user exists and the password matches, generate access and refresh tokens 49 | if (user && (await bcrypt.compare(password, user.password))) { 50 | const userData = { id: user.id, email: user.email }; 51 | const accessToken = generateAccessToken(userData); 52 | const refreshToken = generateRefreshToken(userData); 53 | 54 | return { 55 | id: user.id, 56 | name: user.name, 57 | accessToken, 58 | refreshToken, 59 | }; 60 | } 61 | 62 | // Throw an error if the email or password is invalid 63 | throw new Error("Invalid email or password"); 64 | }; 65 | 66 | const refreshAccessToken = async (refreshToken) => { 67 | try { 68 | // Verify the refresh token 69 | const user = await jwt.verify( 70 | refreshToken, 71 | process.env.REFRESH_TOKEN_SECRET 72 | ); 73 | const userData = { id: user.id, email: user.email }; 74 | // Generate and return a new access token 75 | return generateAccessToken(userData); 76 | } catch (err) { 77 | // Throw an error if the refresh token is invalid 78 | throw new Error("Invalid refresh token"); 79 | } 80 | }; 81 | 82 | export { registerUser, loginUser, refreshAccessToken }; 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Project - example backend 2 | 3 | This is an example backend API for the React project. 4 | 5 | It allows users to register, log in, and manage their collections of things. 6 | 7 | The API is built using Node.js, Express, and PostgreSQL. 8 | 9 | 10 | ## Installation 11 | 12 | ### Prerequisites 13 | 14 | Ensure you have the following installed: 15 | 16 | - Node.js (version 18 or higher) 17 | - npm (version 6 or higher) 18 | - PostgreSQL (version 12 or higher) 19 | 20 | ### Install dependencies 21 | 22 | ``` 23 | npm install 24 | ``` 25 | 26 | ### Environment Variables 27 | 28 | Create a `.env` file in the root of the project - duplicate the `.env.example` file and replace values with your real values: 29 | 30 | ``` 31 | PORT=3001 32 | DB_USER=your_db_user 33 | DB_HOST=your_db_host 34 | DB_NAME=your_db_name 35 | DB_PW=your_db_password 36 | DB_PORT=5432 37 | ACCESS_TOKEN_SECRET=your_access_token_secret 38 | REFRESH_TOKEN_SECRET=your_refresh_token_secret 39 | REACT_APP_DOMAIN=http://localhost:3000 40 | NODE_ENV=development 41 | ``` 42 | 43 | ### Database setup 44 | 45 | See [docs/DB.md](docs/DB.md) for SQL for creating the database 46 | 47 | 48 | ## Running the Server 49 | 50 | Start the server using the following command: 51 | 52 | ``` 53 | npm start 54 | ``` 55 | 56 | The server should now be running on [http://localhost:3001](http://localhost:3001) 57 | 58 | 59 | ## API endpoints 60 | 61 | See [docs/API.md](docs/API.md) for API endpoints 62 | 63 | 64 | ## Tests 65 | 66 | Tests are written with SuperTest, Mocha, Chai and Sinon. 67 | 68 | To run them: 69 | 70 | ``` 71 | npm run test 72 | ``` 73 | 74 | 75 | ## Auth 76 | 77 | The application uses JSON Web Tokens (JWT) to authenticate users. 78 | 79 | - Registering: Users create an account by providing their name, email, password, and a brief bio. The password is securely hashed before storage. 80 | 81 | - Logging In: Users log in with their email and password. If the credentials are correct, the server issues an access token and a refresh token. The access token is short-lived, and the refresh token is used to obtain a new access token when the current one expires. 82 | 83 | - Access Token: This token is included in the Authorization header of API requests to authenticate the user. It has a short expiry time (e.g., a few minutes). 84 | 85 | - Refresh Token: This token is stored as an HTTP-only cookie and is used to get a new access token without requiring the user to log in again. It has a longer expiry time (e.g., 7 days). 86 | 87 | - Refreshing Tokens: When the access token expires, the client can use the refresh token to request a new access token by calling the /refresh-token endpoint. 88 | 89 | - Logging Out: Users log out by calling the /logout endpoint, which clears the refresh token cookie from the browser. 90 | 91 | - Middleware: The authenticateToken middleware is used to protect routes that require authentication. It checks the validity of the access token and attaches the user's information to the request object. -------------------------------------------------------------------------------- /test/routes/users.test.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import request from "supertest"; 3 | import { expect } from "chai"; 4 | import sinon from "sinon"; 5 | import jwt from "jsonwebtoken"; 6 | import usersRouter from "../../src/routes/users.js"; 7 | import pool from "../../src/db.js"; 8 | import { initTestDb } from "../init-db.js"; 9 | import dotenv from "dotenv"; 10 | 11 | dotenv.config(); 12 | 13 | describe("Users API", () => { 14 | let app; 15 | let token; 16 | 17 | before(async () => { 18 | await initTestDb(); 19 | }); 20 | 21 | beforeEach(async () => { 22 | app = express(); 23 | app.use(express.json()); 24 | 25 | // Reset the test database before each test 26 | await pool.query("TRUNCATE things, users RESTART IDENTITY CASCADE"); 27 | 28 | // Insert mock users 29 | await pool.query(` 30 | INSERT INTO users (name, email, password, bio) 31 | VALUES 32 | ('John Doe', 'johndoe@example.com', 'password123', 'Loves collecting things.'), 33 | ('Jane Doe', 'janedoe@example.com', 'password123', 'Loves collecting other things.'); 34 | `); 35 | 36 | // Insert mock things 37 | await pool.query(` 38 | INSERT INTO things (name, description, user_id) 39 | VALUES 40 | ('Thing 1', 'Description 1', (SELECT id FROM users WHERE email = 'johndoe@example.com')), 41 | ('Thing 2', 'Description 2', (SELECT id FROM users WHERE email = 'johndoe@example.com')), 42 | ('Thing 3', 'Description 3', (SELECT id FROM users WHERE email = 'janedoe@example.com')); 43 | `); 44 | 45 | app.use("/api/users", usersRouter); 46 | 47 | // Generate a valid token 48 | const user = { id: 1, email: "johndoe@example.com" }; 49 | token = jwt.sign(user, process.env.ACCESS_TOKEN_SECRET, { 50 | expiresIn: "15m", 51 | }); 52 | }); 53 | 54 | afterEach(() => { 55 | sinon.restore(); 56 | }); 57 | 58 | describe("GET /api/users", () => { 59 | it("should return all users", async () => { 60 | const res = await request(app) 61 | .get("/api/users") 62 | .set("Authorization", `Bearer ${token}`); 63 | expect(res.status).to.equal(200); 64 | expect(res.body).to.have.lengthOf(2); 65 | }); 66 | }); 67 | 68 | describe("GET /api/users/:id", () => { 69 | it("should return a specific user", async () => { 70 | const res = await request(app) 71 | .get("/api/users/1") 72 | .set("Authorization", `Bearer ${token}`); 73 | expect(res.status).to.equal(200); 74 | expect(res.body).to.include({ id: 1, name: "John Doe" }); 75 | }); 76 | 77 | it("should return 404 if user not found", async () => { 78 | const res = await request(app) 79 | .get("/api/users/999") 80 | .set("Authorization", `Bearer ${token}`); 81 | expect(res.status).to.equal(404); 82 | expect(res.body).to.deep.equal({ error: "User not found" }); 83 | }); 84 | }); 85 | 86 | describe("GET /api/users/:id/things", () => { 87 | it("should return things for a specific user", async () => { 88 | const res = await request(app) 89 | .get("/api/users/1/things") 90 | .set("Authorization", `Bearer ${token}`); 91 | expect(res.status).to.equal(200); 92 | expect(res.body).to.have.lengthOf(2); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /docs/DB.md: -------------------------------------------------------------------------------- 1 | # DB 2 | 3 | ## Create database 4 | 5 | Create database: 6 | 7 | ```sql 8 | CREATE DATABASE yourdatabase; 9 | ``` 10 | 11 | Switch to database: 12 | 13 | ```sql 14 | \c yourdatabase; 15 | ``` 16 | 17 | Create user table: 18 | 19 | ```sql 20 | CREATE TABLE users ( 21 | id SERIAL PRIMARY KEY, 22 | name VARCHAR(100) NOT NULL, 23 | email VARCHAR(100) NOT NULL UNIQUE, 24 | password VARCHAR(100) NOT NULL, 25 | bio TEXT 26 | ); 27 | ``` 28 | 29 | Create things table: 30 | 31 | ```sql 32 | CREATE TABLE things ( 33 | id SERIAL PRIMARY KEY, 34 | name VARCHAR(100) NOT NULL, 35 | description TEXT, 36 | user_id INTEGER REFERENCES users(id) ON DELETE CASCADE 37 | ); 38 | ``` 39 | 40 | Create tags table: 41 | 42 | ```sql 43 | CREATE TABLE tags ( 44 | id SERIAL PRIMARY KEY, 45 | name VARCHAR(100) NOT NULL UNIQUE 46 | ); 47 | ``` 48 | 49 | Create tags-things join table: 50 | 51 | ```sql 52 | CREATE TABLE tags_things ( 53 | tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE, 54 | thing_id INTEGER REFERENCES things(id) ON DELETE CASCADE, 55 | PRIMARY KEY (tag_id, thing_id) 56 | ); 57 | ``` 58 | 59 | ## Populate database 60 | 61 | Insert a User: 62 | 63 | ```sql 64 | INSERT INTO users (name, email, password, bio) VALUES ('John Doe', 'john@example.com', 'hashedpassword', 'This is my bio'); 65 | ``` 66 | 67 | Insert a Thing: 68 | 69 | ```sql 70 | INSERT INTO things (name, description, user_id) VALUES ('My Thing', 'This is a description', 1); 71 | ``` 72 | 73 | Insert a Tag: 74 | 75 | ```sql 76 | INSERT INTO tags (name) VALUES ('Tag1'); 77 | ``` 78 | 79 | Associate a Tag with a Thing: 80 | 81 | ```sql 82 | INSERT INTO tags_things (tag_id, thing_id) VALUES (1, 1); 83 | ``` 84 | 85 | ## Add indexes 86 | 87 | ```sql 88 | CREATE INDEX idx_users_email ON users(email); 89 | CREATE INDEX idx_things_user_id ON things(user_id); 90 | ``` 91 | 92 | --- 93 | 94 | SQL: 95 | 96 | ```sql 97 | -- Create database 98 | CREATE DATABASE yourdatabase; 99 | 100 | -- Switch to database 101 | \c yourdatabase; 102 | 103 | -- Create users table 104 | CREATE TABLE users ( 105 | id SERIAL PRIMARY KEY, 106 | name VARCHAR(100) NOT NULL, 107 | email VARCHAR(100) NOT NULL UNIQUE, 108 | password VARCHAR(100) NOT NULL, 109 | bio TEXT 110 | ); 111 | 112 | -- Create things table 113 | CREATE TABLE things ( 114 | id SERIAL PRIMARY KEY, 115 | name VARCHAR(100) NOT NULL, 116 | description TEXT, 117 | user_id INTEGER REFERENCES users(id) ON DELETE CASCADE 118 | ); 119 | 120 | -- Create tags table 121 | CREATE TABLE tags ( 122 | id SERIAL PRIMARY KEY, 123 | name VARCHAR(100) NOT NULL UNIQUE 124 | ); 125 | 126 | -- Create tags-things join table 127 | CREATE TABLE tags_things ( 128 | tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE, 129 | thing_id INTEGER REFERENCES things(id) ON DELETE CASCADE, 130 | PRIMARY KEY (tag_id, thing_id) 131 | ); 132 | 133 | -- Populate database 134 | -- Insert a User 135 | INSERT INTO users (name, email, password, bio) VALUES ('John Doe', 'john@example.com', 'hashedpassword', 'This is my bio'); 136 | 137 | -- Insert a Thing 138 | INSERT INTO things (name, description, user_id) VALUES ('My Thing', 'This is a description', 1); 139 | 140 | -- Insert a Tag 141 | INSERT INTO tags (name) VALUES ('Tag1'); 142 | 143 | -- Associate a Tag with a Thing 144 | INSERT INTO tags_things (tag_id, thing_id) VALUES (1, 1); 145 | 146 | -- Add indexes 147 | CREATE INDEX idx_users_email ON users(email); 148 | CREATE INDEX idx_things_user_id ON things(user_id); 149 | ``` -------------------------------------------------------------------------------- /test/auth.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import sinon from "sinon"; 3 | import bcrypt from "bcryptjs"; 4 | import jwt from "jsonwebtoken"; 5 | import pool from "../src/db.js"; 6 | import { 7 | registerUser, 8 | loginUser, 9 | refreshAccessToken, 10 | } from "../src/services/auth.js"; 11 | 12 | describe("Auth functions", () => { 13 | let queryStub; 14 | let bcryptHashStub; 15 | let bcryptCompareStub; 16 | let jwtSignStub; 17 | let jwtVerifyStub; 18 | 19 | beforeEach(() => { 20 | queryStub = sinon.stub(pool, "query"); 21 | bcryptHashStub = sinon.stub(bcrypt, "hash"); 22 | bcryptCompareStub = sinon.stub(bcrypt, "compare"); 23 | jwtSignStub = sinon.stub(jwt, "sign"); 24 | jwtVerifyStub = sinon.stub(jwt, "verify"); 25 | }); 26 | 27 | afterEach(() => { 28 | sinon.restore(); 29 | }); 30 | 31 | describe("registerUser", () => { 32 | it("should throw an error if email already exists", async () => { 33 | queryStub.resolves({ rows: [{ email: "test@example.com" }] }); 34 | 35 | try { 36 | await registerUser( 37 | "Test User", 38 | "test@example.com", 39 | "password123", 40 | "Bio" 41 | ); 42 | expect.fail("Expected error not thrown"); 43 | } catch (err) { 44 | expect(err.message).to.equal("Email already exists"); 45 | } 46 | }); 47 | 48 | it("should register a new user", async () => { 49 | queryStub.onFirstCall().resolves({ rows: [] }); 50 | queryStub.onSecondCall().resolves({ 51 | rows: [ 52 | { id: 1, name: "Test User", email: "test@example.com", bio: "Bio" }, 53 | ], 54 | }); 55 | bcryptHashStub.resolves("hashedpassword"); 56 | 57 | const user = await registerUser( 58 | "Test User", 59 | "test@example.com", 60 | "password123", 61 | "Bio" 62 | ); 63 | 64 | expect(user).to.deep.equal({ 65 | id: 1, 66 | name: "Test User", 67 | email: "test@example.com", 68 | bio: "Bio", 69 | }); 70 | expect(queryStub.calledTwice).to.be.true; 71 | expect(bcryptHashStub.calledOnce).to.be.true; 72 | }); 73 | }); 74 | 75 | describe("loginUser", () => { 76 | it("should throw an error for invalid email or password", async () => { 77 | queryStub.resolves({ rows: [] }); 78 | 79 | try { 80 | await loginUser("test@example.com", "password123"); 81 | expect.fail("Expected error not thrown"); 82 | } catch (err) { 83 | expect(err.message).to.equal("Invalid email or password"); 84 | } 85 | }); 86 | 87 | it("should log in a user and return tokens", async () => { 88 | queryStub.resolves({ 89 | rows: [ 90 | { 91 | id: 1, 92 | name: "Test User", 93 | email: "test@example.com", 94 | password: "hashedpassword", 95 | }, 96 | ], 97 | }); 98 | bcryptCompareStub.resolves(true); 99 | jwtSignStub.onFirstCall().returns("accesstoken"); 100 | jwtSignStub.onSecondCall().returns("refreshtoken"); 101 | 102 | const result = await loginUser("test@example.com", "password123"); 103 | 104 | expect(result).to.deep.equal({ 105 | id: 1, 106 | name: "Test User", 107 | accessToken: "accesstoken", 108 | refreshToken: "refreshtoken", 109 | }); 110 | expect(queryStub.calledOnce).to.be.true; 111 | expect(bcryptCompareStub.calledOnce).to.be.true; 112 | expect(jwtSignStub.calledTwice).to.be.true; 113 | }); 114 | }); 115 | 116 | describe("refreshAccessToken", () => { 117 | it("should throw an error for invalid refresh token", async () => { 118 | jwtVerifyStub.throws(new Error("Invalid refresh token")); 119 | 120 | try { 121 | await refreshAccessToken("invalidtoken"); 122 | expect.fail("Expected error not thrown"); 123 | } catch (err) { 124 | expect(err.message).to.equal("Invalid refresh token"); 125 | } 126 | }); 127 | 128 | it("should generate a new access token", async () => { 129 | jwtVerifyStub.resolves({ id: 1, email: "test@example.com" }); 130 | jwtSignStub.returns("newaccesstoken"); 131 | 132 | const accessToken = await refreshAccessToken("validrefreshtoken"); 133 | 134 | expect(accessToken).to.equal("newaccesstoken"); 135 | expect(jwtVerifyStub.calledOnce).to.be.true; 136 | expect(jwtSignStub.calledOnce).to.be.true; 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /test/routes/auth.test.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import request from "supertest"; 3 | import { expect } from "chai"; 4 | import sinon from "sinon"; 5 | import bcrypt from "bcryptjs"; 6 | import authRouter from "../../src/routes/auth.js"; 7 | import pool from "../../src/db.js"; 8 | import { initTestDb } from "../init-db.js"; 9 | import dotenv from "dotenv"; 10 | import cookieParser from "cookie-parser"; 11 | 12 | dotenv.config(); 13 | 14 | describe("Auth API", () => { 15 | let app; 16 | let refreshToken; 17 | 18 | before(async () => { 19 | await initTestDb(); 20 | }); 21 | 22 | beforeEach(async () => { 23 | app = express(); 24 | app.use(express.json()); 25 | app.use(cookieParser()); 26 | 27 | // Reset the test database before each test 28 | await pool.query("TRUNCATE things, users RESTART IDENTITY CASCADE"); 29 | 30 | // Insert mock user with hashed password 31 | const hashedPassword = await bcrypt.hash("password123", 10); 32 | await pool.query( 33 | ` 34 | INSERT INTO users (name, email, password, bio) 35 | VALUES ('John Doe', 'johndoe@example.com', $1, 'Loves collecting things.'); 36 | `, 37 | [hashedPassword] 38 | ); 39 | 40 | app.use("/api/auth", authRouter); 41 | }); 42 | 43 | afterEach(() => { 44 | sinon.restore(); 45 | }); 46 | 47 | describe("POST /api/auth/register", () => { 48 | it("should register a new user", async () => { 49 | const res = await request(app).post("/api/auth/register").send({ 50 | name: "Jane Doe", 51 | email: "janedoe@example.com", 52 | password: "password123", 53 | bio: "Loves collecting other things.", 54 | }); 55 | expect(res.status).to.equal(201); 56 | expect(res.body).to.include({ 57 | name: "Jane Doe", 58 | email: "janedoe@example.com", 59 | }); 60 | }); 61 | 62 | it("should return 400 if email already exists", async () => { 63 | const res = await request(app).post("/api/auth/register").send({ 64 | name: "John Doe", 65 | email: "johndoe@example.com", 66 | password: "password123", 67 | bio: "Loves collecting things.", 68 | }); 69 | expect(res.status).to.equal(400); 70 | expect(res.body).to.deep.equal({ error: "Email already exists" }); 71 | }); 72 | }); 73 | 74 | describe("POST /api/auth/login", () => { 75 | it("should log in a user", async () => { 76 | const res = await request(app).post("/api/auth/login").send({ 77 | email: "johndoe@example.com", 78 | password: "password123", 79 | }); 80 | expect(res.status).to.equal(200); 81 | expect(res.body).to.include({ name: "John Doe" }); 82 | expect(res.body).to.have.property("accessToken"); 83 | expect(res.headers["set-cookie"]).to.exist; 84 | 85 | const cookie = res.headers["set-cookie"][0]; 86 | refreshToken = cookie.split(";")[0].split("=")[1]; 87 | }); 88 | 89 | it("should return 400 for invalid email or password", async () => { 90 | const res = await request(app).post("/api/auth/login").send({ 91 | email: "johndoe@example.com", 92 | password: "wrongpassword", 93 | }); 94 | expect(res.status).to.equal(400); 95 | expect(res.body).to.deep.equal({ error: "Invalid email or password" }); 96 | }); 97 | }); 98 | 99 | describe("POST /api/auth/refresh-token", () => { 100 | beforeEach(async () => { 101 | const res = await request(app).post("/api/auth/login").send({ 102 | email: "johndoe@example.com", 103 | password: "password123", 104 | }); 105 | refreshToken = res.headers["set-cookie"][0].split(";")[0].split("=")[1]; 106 | }); 107 | 108 | it("should refresh the access token", async () => { 109 | const res = await request(app) 110 | .post("/api/auth/refresh-token/") 111 | .set("Cookie", `refreshToken=${refreshToken}`) 112 | .send({}); 113 | expect(res.status).to.equal(200); 114 | expect(res.body).to.have.property("accessToken"); 115 | }); 116 | 117 | it("should return 401 if refresh token is missing", async () => { 118 | const res = await request(app).post("/api/auth/refresh-token").send({}); 119 | expect(res.status).to.equal(401); 120 | }); 121 | 122 | it("should return 403 if refresh token is invalid", async () => { 123 | const res = await request(app) 124 | .post("/api/auth/refresh-token") 125 | .set("Cookie", "refreshToken=invalidtoken") 126 | .send({}); 127 | expect(res.status).to.equal(403); 128 | expect(res.body).to.deep.equal({ error: "Invalid refresh token" }); 129 | }); 130 | }); 131 | 132 | describe("POST /api/auth/logout", () => { 133 | it("should log out a user", async () => { 134 | const res = await request(app).post("/api/auth/logout"); 135 | expect(res.status).to.equal(200); 136 | expect(res.body).to.deep.equal({ message: "Logged out" }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /test/routes/my-things.test.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import request from "supertest"; 3 | import { expect } from "chai"; 4 | import sinon from "sinon"; 5 | import jwt from "jsonwebtoken"; 6 | import myThingsRouter from "../../src/routes/my-things.js"; 7 | import pool from "../../src/db.js"; 8 | import { initTestDb } from "../init-db.js"; 9 | import dotenv from "dotenv"; 10 | 11 | dotenv.config(); 12 | 13 | describe("My Things API", () => { 14 | let app; 15 | let token; 16 | 17 | before(async () => { 18 | await initTestDb(); 19 | }); 20 | 21 | beforeEach(async () => { 22 | app = express(); 23 | app.use(express.json()); 24 | 25 | // Reset the test database before each test 26 | await pool.query("TRUNCATE things, users RESTART IDENTITY CASCADE"); 27 | 28 | // Insert mock users 29 | await pool.query(` 30 | INSERT INTO users (name, email, password, bio) 31 | VALUES 32 | ('John Doe', 'johndoe@example.com', 'password123', 'Loves collecting things.'), 33 | ('Jane Doe', 'janedoe@example.com', 'password123', 'Loves collecting other things.'); 34 | `); 35 | 36 | // Generate a valid token 37 | const user = { id: 1, email: "johndoe@example.com" }; 38 | token = jwt.sign(user, process.env.ACCESS_TOKEN_SECRET, { 39 | expiresIn: "1h", 40 | }); 41 | 42 | app.use("/api/my-things", myThingsRouter); 43 | }); 44 | 45 | afterEach(() => { 46 | sinon.restore(); 47 | }); 48 | 49 | describe("GET /api/my-things", () => { 50 | it("should return all things for the authenticated user", async () => { 51 | // Insert mock things for the authenticated user 52 | await pool.query(` 53 | INSERT INTO things (name, description, user_id) 54 | VALUES 55 | ('Thing 1', 'Description 1', (SELECT id FROM users WHERE email = 'johndoe@example.com')), 56 | ('Thing 2', 'Description 2', (SELECT id FROM users WHERE email = 'johndoe@example.com')); 57 | `); 58 | 59 | const res = await request(app) 60 | .get("/api/my-things") 61 | .set("Authorization", `Bearer ${token}`); 62 | expect(res.status).to.equal(200); 63 | expect(res.body).to.have.lengthOf(2); 64 | }); 65 | }); 66 | 67 | describe("POST /api/my-things", () => { 68 | it("should add a new thing for the authenticated user", async () => { 69 | const newThing = { name: "New Thing", description: "New Description" }; 70 | const res = await request(app) 71 | .post("/api/my-things") 72 | .set("Authorization", `Bearer ${token}`) 73 | .send(newThing); 74 | expect(res.status).to.equal(201); 75 | expect(res.body).to.include({ 76 | name: "New Thing", 77 | description: "New Description", 78 | }); 79 | }); 80 | }); 81 | 82 | describe("PUT /api/my-things/:id", () => { 83 | beforeEach(async () => { 84 | // Insert a mock thing for the authenticated user 85 | await pool.query(` 86 | INSERT INTO things (name, description, user_id) 87 | VALUES ('Thing 1', 'Description 1', (SELECT id FROM users WHERE email = 'johndoe@example.com')); 88 | `); 89 | }); 90 | 91 | it("should update a thing for the authenticated user", async () => { 92 | const updatedThing = { 93 | name: "Updated Thing", 94 | description: "Updated Description", 95 | }; 96 | const res = await request(app) 97 | .put("/api/my-things/1") 98 | .set("Authorization", `Bearer ${token}`) 99 | .send(updatedThing); 100 | expect(res.status).to.equal(200); 101 | expect(res.body).to.include({ 102 | name: "Updated Thing", 103 | description: "Updated Description", 104 | }); 105 | }); 106 | 107 | it("should return 404 if the thing is not found", async () => { 108 | const updatedThing = { 109 | name: "Updated Thing", 110 | description: "Updated Description", 111 | }; 112 | const res = await request(app) 113 | .put("/api/my-things/999") 114 | .set("Authorization", `Bearer ${token}`) 115 | .send(updatedThing); 116 | expect(res.status).to.equal(404); 117 | expect(res.body).to.deep.equal({ error: "Thing not found" }); 118 | }); 119 | }); 120 | 121 | describe("DELETE /api/my-things/:id", () => { 122 | beforeEach(async () => { 123 | // Insert a mock thing for the authenticated user 124 | await pool.query(` 125 | INSERT INTO things (name, description, user_id) 126 | VALUES ('Thing 1', 'Description 1', (SELECT id FROM users WHERE email = 'johndoe@example.com')); 127 | `); 128 | }); 129 | 130 | it("should delete a thing for the authenticated user", async () => { 131 | const res = await request(app) 132 | .delete("/api/my-things/1") 133 | .set("Authorization", `Bearer ${token}`); 134 | expect(res.status).to.equal(204); 135 | }); 136 | 137 | it("should return 404 if the thing is not found", async () => { 138 | const res = await request(app) 139 | .delete("/api/my-things/999") 140 | .set("Authorization", `Bearer ${token}`); 141 | expect(res.status).to.equal(404); 142 | expect(res.body).to.deep.equal({ error: "Thing not found" }); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # API Endpoints 2 | 3 | ## Auth 4 | 5 | ### `POST /api/auth/register` 6 | 7 | Registers a new user. 8 | 9 | - **Request:** 10 | - Body: 11 | ```json 12 | { 13 | "name": "John Doe", 14 | "email": "johndoe@example.com", 15 | "password": "password123", 16 | "bio": "Loves collecting things." 17 | } 18 | ``` 19 | 20 | - **Response:** 21 | - Status: `201 Created` 22 | - Body: 23 | ```json 24 | { 25 | "id": 1, 26 | "name": "John Doe", 27 | "email": "johndoe@example.com", 28 | "bio": "Loves collecting things." 29 | } 30 | ``` 31 | 32 | ### `POST /api/auth/login` 33 | 34 | Logs in a user and returns access and refresh tokens. 35 | 36 | - **Request:** 37 | - Body: 38 | ```json 39 | { 40 | "email": "johndoe@example.com", 41 | "password": "password123" 42 | } 43 | ``` 44 | 45 | - **Response:** 46 | - Status: `200 OK` 47 | - Body: 48 | ```json 49 | { 50 | "id": 1, 51 | "name": "John Doe", 52 | "accessToken": "access-token", 53 | "refreshToken": "refresh-token" 54 | } 55 | ``` 56 | 57 | ### `POST /api/auth/refresh-token` 58 | 59 | Refreshes the access token using the refresh token. 60 | 61 | - **Request:** 62 | - Cookies: 63 | - `refreshToken`: "refresh-token" 64 | 65 | - **Response:** 66 | - Status: `200 OK` 67 | - Body: 68 | ```json 69 | { 70 | "accessToken": "new-access-token" 71 | } 72 | ``` 73 | 74 | ### `POST /api/auth/logout` 75 | 76 | Logs out a user and clears the refresh token cookie. 77 | 78 | - **Request:** None 79 | 80 | - **Response:** 81 | - Status: `200 OK` 82 | - Body: 83 | ```json 84 | { 85 | "message": "Logged out" 86 | } 87 | ``` 88 | 89 | ## Things 90 | 91 | ### `GET /api/things` 92 | 93 | Gets all things. No authentication required. 94 | 95 | - **Request:** None 96 | 97 | - **Response:** 98 | - Status: `200 OK` 99 | - Body: 100 | ```json 101 | [ 102 | { 103 | "id": 1, 104 | "name": "Thing 1", 105 | "description": "Description of Thing 1", 106 | "user_id": 1, 107 | "user_name": "John Doe" 108 | }, 109 | { 110 | "id": 2, 111 | "name": "Thing 2", 112 | "description": "Description of Thing 2", 113 | "user_id": 2, 114 | "user_name": "Jane Doe" 115 | } 116 | ] 117 | ``` 118 | 119 | ### `GET /api/things/:id` 120 | 121 | Gets a specific thing by ID. No authentication required. 122 | 123 | - **Request:** 124 | - Params: `id` 125 | 126 | - **Response:** 127 | - Status: `200 OK` 128 | - Body: 129 | ```json 130 | { 131 | "id": 1, 132 | "name": "Thing 1", 133 | "description": "Description of Thing 1", 134 | "user_id": 1, 135 | "user_name": "John Doe" 136 | } 137 | ``` 138 | 139 | ## Users 140 | 141 | ### `GET /api/users` 142 | 143 | Gets all users. Requires authentication. 144 | 145 | - **Request:** None 146 | 147 | - **Response:** 148 | - Status: `200 OK` 149 | - Body: 150 | ```json 151 | [ 152 | { 153 | "id": 1, 154 | "name": "John Doe", 155 | "email": "johndoe@example.com", 156 | "bio": "Loves collecting things." 157 | }, 158 | { 159 | "id": 2, 160 | "name": "Jane Doe", 161 | "email": "janedoe@example.com", 162 | "bio": "Loves collecting other things." 163 | } 164 | ] 165 | ``` 166 | 167 | ### `GET /api/users/:id` 168 | 169 | Gets a specific user by ID. Requires authentication. 170 | 171 | - **Request:** 172 | - Params: `id` 173 | 174 | - **Response:** 175 | - Status: `200 OK` 176 | - Body: 177 | ```json 178 | { 179 | "id": 1, 180 | "name": "John Doe", 181 | "email": "johndoe@example.com", 182 | "bio": "Loves collecting things." 183 | } 184 | ``` 185 | 186 | ### `GET /api/users/:id/things` 187 | 188 | Gets things for a specific user by ID. Requires authentication. 189 | 190 | - **Request:** 191 | - Params: `id` 192 | 193 | - **Response:** 194 | - Status: `200 OK` 195 | - Body: 196 | ```json 197 | [ 198 | { 199 | "id": 1, 200 | "name": "Thing 1", 201 | "description": "Description of Thing 1", 202 | "user_id": 1 203 | }, 204 | { 205 | "id": 2, 206 | "name": "Thing 2", 207 | "description": "Description of Thing 2", 208 | "user_id": 1 209 | } 210 | ] 211 | ``` 212 | 213 | ## My Things 214 | 215 | ### `GET /api/my-things` 216 | 217 | Gets all things for the logged-in user. Requires authentication. 218 | 219 | - **Request:** None 220 | 221 | - **Response:** 222 | - Status: `200 OK` 223 | - Body: 224 | ```json 225 | [ 226 | { 227 | "id": 1, 228 | "name": "My Thing 1", 229 | "description": "Description of My Thing 1", 230 | "user_id": 1 231 | }, 232 | { 233 | "id": 2, 234 | "name": "My Thing 2", 235 | "description": "Description of My Thing 2", 236 | "user_id": 1 237 | } 238 | ] 239 | ``` 240 | 241 | ### `POST /api/my-things` 242 | 243 | Adds a new thing for the logged-in user. Requires authentication. 244 | 245 | - **Request:** 246 | - Body: 247 | ```json 248 | { 249 | "name": "New Thing", 250 | "description": "Description of New Thing" 251 | } 252 | ``` 253 | 254 | - **Response:** 255 | - Status: `201 Created` 256 | - Body: 257 | ```json 258 | { 259 | "id": 3, 260 | "name": "New Thing", 261 | "description": "Description of New Thing", 262 | "user_id": 1 263 | } 264 | ``` 265 | 266 | ### `PUT /api/my-things/:id` 267 | 268 | Updates a specific thing for the logged-in user. Requires authentication. 269 | 270 | - **Request:** 271 | - Params: `id` 272 | - Body: 273 | ```json 274 | { 275 | "name": "Updated Thing", 276 | "description": "Updated Description" 277 | } 278 | ``` 279 | 280 | - **Response:** 281 | - Status: `200 OK` 282 | - Body: 283 | ```json 284 | { 285 | "id": 1, 286 | "name": "Updated Thing", 287 | "description": "Updated Description", 288 | "user_id": 1 289 | } 290 | ``` 291 | 292 | ### `DELETE /api/my-things/:id` 293 | 294 | Deletes a specific thing for the logged-in user. Requires authentication. 295 | 296 | - **Request:** 297 | - Params: `id` 298 | 299 | - **Response:** 300 | - Status: `204 No Content` 301 | --------------------------------------------------------------------------------