diff --git a/app/server.js b/app/server.js index 5201d84d..9882384e 100644 --- a/app/server.js +++ b/app/server.js @@ -3,8 +3,9 @@ const bodyParser = require('body-parser'); const jwt = require('jsonwebtoken'); const swaggerUi = require('swagger-ui-express'); const YAML = require('yamljs'); -const swaggerDocument = YAML.load('./swagger.yaml'); // Replace './swagger.yaml' with the path to your Swagger file +const swaggerDocument = YAML.load('./swagger.yaml'); const app = express(); +require("dotenv").config(); app.use(bodyParser.json()); @@ -13,6 +14,8 @@ const users = require('../initial-data/users.json'); const brands = require('../initial-data/brands.json'); const products = require('../initial-data/products.json'); +const jwtSecret = process.env.jwtSecret; + // Error handling app.use((err, req, res, next) => { console.error(err.stack); @@ -28,4 +31,104 @@ app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); +// Middleware for authentication +const authenticateToken = (req, res, next) => { + const token = req.headers['authorization']; + if (!token) return res.status(401).json({ error: 'Unauthorized' }); + + jwt.verify(token, jwtSecret, (err, user) => { + if (err) return res.status(401).json({ error: 'Unauthorized' }); + req.user = user; + next(); + }); +}; + +// Routes +app.get('/brands', (req, res) => { + res.json(brands); +}); + +app.get('/brands/:id/products', (req, res) => { + const brandId = req.params.id; + const brandProducts = products.filter(product => product.categoryId === brandId); + if (brandProducts.length === 0) { + return res.status(404).json({ error: 'Brand not found' }); + } + res.json(brandProducts); +}); + +app.get('/products', (req, res) => { + res.json(products); +}); + +app.post('/login', (req, res) => { + if (req.body.username === undefined || req.body.password === undefined) { + return res.status(400).json({ error: "Username and password are required" }); + } + const { username, password } = req.body; + const user = users.find( + (u) => u.login.username === username && u.login.password === password + ); + if (!user) { + return res.status(401).json({ error: "Invalid credentials" }); + } + const token = jwt.sign({ username: user.login.username }, jwtSecret, { + expiresIn: "1h", + }); + res.json({ token }); +}); + +app.get('/me/cart', authenticateToken, (req, res) => { + const userCart = users.find(u => u.login.username === req.user.username).cart; + if (!userCart) { + return res.status(401).json({ error: "Unauthorized" }); + } + res.json(userCart); +}); + +app.post('/me/cart', authenticateToken, (req, res) => { + const { id } = req.body; + const userCart = users.find((u) => u.login.username === req.user.username).cart; + if (!userCart) { + return res.status(401).json({ error: "Unauthorized" }); + } + const product = products.find((item) => item.id === id); + const existingProduct = userCart.find((item) => item.id === id); + if (existingProduct) { + existingProduct.quantity += 1; + } else { + userCart.push({ ...product, quantity: 1 }); + } + res.json(userCart); +}); + +app.delete('/me/cart/:productId', authenticateToken, (req, res) => { + const productId = req.params.productId; + const user = users.find(u => u.id === req.user.id); + if (!user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const productIndex = user.cart.findIndex(item => item.id === productId); + if (productIndex === -1) { + return res.status(404).json({ error: 'Product not found in cart' }); + } + user.cart.splice(productIndex, 1); + res.json(user.cart); +}); + +app.post('/me/cart/:productId', authenticateToken, (req, res) => { + const productId = req.params.id; + const { quantity } = req.body; + const user = users.find(u => u.id === req.user.id); + if (!user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const product = user.cart.find(item => item.productId === productId); + if (!product) { + return res.status(404).json({ error: 'Product not found in cart' }); + } + product.quantity = quantity; + res.json(user.cart); +}); + module.exports = app; diff --git a/package-lock.json b/package-lock.json index ab397dea..b56f05ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "body-parser": "^1.18.2", + "dotenv": "^16.5.0", "express": "^4.18.2", "finalhandler": "latest", "http": "latest", @@ -560,6 +561,18 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2543,6 +2556,11 @@ "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", "dev": true }, + "dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==" + }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", diff --git a/package.json b/package.json index afe392cf..f81cdd06 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "license": "ISC", "dependencies": { "body-parser": "^1.18.2", + "dotenv": "^16.5.0", "express": "^4.18.2", "finalhandler": "latest", "http": "latest", diff --git a/swagger.yaml b/swagger.yaml index 1c2eb22f..fc4579a9 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -1,11 +1,257 @@ -swagger: '2.0' +swagger: "2.0" info: - version: '1.0.0' - title: 'E-Commerce API' - description: 'API for managing brands, products, and user cart' -host: 'localhost:3000' + version: "1.0.0" + title: "E-Commerce API" + description: "API for managing brands, products, and user cart" +host: "localhost:3000" schemes: - - 'http' -basePath: '/api' + - "http" +basePath: "/api" produces: - - 'application/json' + - "application/json" +paths: + /brands: + get: + summary: "Get all brands" + description: "Returns a list of all brands" + responses: + 200: + description: "A list of brands" + schema: + type: "array" + items: + $ref: "#/definitions/Brand" + default: + description: "Unexpected error" + schema: + $ref: "#/definitions/Error" + /brands/{id}/products: + get: + summary: "Get all products for a brand" + description: "Returns a list of all products for a specific brand" + parameters: + - name: "id" + in: "path" + required: true + type: "string" + description: "ID of the brand to fetch products for" + responses: + 200: + description: "A list of products for the specified brand" + schema: + type: "array" + items: + $ref: "#/definitions/Product" + 404: + description: "Brand not found" + schema: + $ref: "#/definitions/Error" + /products: + get: + summary: "Get all products" + description: "Returns a list of all products" + responses: + 200: + description: "A list of products" + schema: + type: "array" + items: + $ref: "#/definitions/Product" + 500: + description: "Internal server error" + schema: + $ref: "#/definitions/Error" + /login: + post: + summary: "User login" + description: "Logs in a user and returns a token" + parameters: + - name: "body" + in: "body" + required: true + schema: + $ref: "#/definitions/LoginRequest" + responses: + 200: + description: "Login successful" + schema: + $ref: "#/definitions/LoginResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/Error" + 401: + description: "Invalid credentials" + schema: + $ref: "#/definitions/Error" + /me/cart: + get: + summary: "Get user cart" + description: "Returns the current user's cart" + responses: + 200: + description: "User cart" + schema: + $ref: "#/definitions/Cart" + 401: + description: "Unauthorized" + schema: + $ref: "#/definitions/Error" + post: + summary: "Add product to cart" + description: "Adds a product to the current user's cart" + parameters: + - name: "Authorization" + in: "header" + required: true + type: "string" + description: "Token for authentication" + - name: "id" + in: "body" + required: true + type: "object" + description: "ID of the product to add to cart" + example: + id: "2" + responses: + 200: + description: "Product added to cart" + schema: + $ref: "#/definitions/Cart" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/Error" + 401: + description: "Unauthorized" + schema: + $ref: "#/definitions/Error" + /me/cart/{productId}: + delete: + summary: "Remove product from cart" + description: "Removes a product from the current user's cart" + parameters: + - name: "Authorization" + in: "header" + required: true + type: "string" + description: "Token for authentication" + - name: "productId" + in: "path" + required: true + type: "string" + description: "ID of the product to remove from cart" + responses: + 200: + description: "Product removed from cart" + schema: + $ref: "#/definitions/Cart" + 404: + description: "Product not found in cart" + schema: + $ref: "#/definitions/Error" + 401: + description: "Unauthorized" + schema: + $ref: "#/definitions/Error" + post: + summary: "Update product quantity in cart" + description: "Updates the quantity of a product in the current user's cart" + parameters: + - name: "Authorization" + in: "header" + required: true + type: "string" + description: "Token for authentication" + - name: "productId" + in: "path" + required: true + type: "string" + description: "ID of the product to update in cart" + - name: "quantity" + in: "body" + required: true + type: "string" + description: "New quantity of the product in the cart" + example: + quantity: 3 + responses: + 200: + description: "Product quantity updated in cart" + schema: + $ref: "#/definitions/Cart" + 404: + description: "Product not found in cart" + schema: + $ref: "#/definitions/Error" + 401: + description: "Unauthorized" + schema: + $ref: "#/definitions/Error" +definitions: + Brand: + type: "object" + properties: + id: + type: "string" + description: "The ID of the brand" + name: + type: "string" + description: "The name of the brand" + Cart: + type: "array" + description: "A list of products in the cart" + items: + type: "object" + properties: + id: + type: "string" + description: "The ID of the product" + quantity: + type: "string" + description: "The quantity of the product in the cart" + Product: + type: "object" + properties: + id: + type: "string" + description: "The ID of the product" + categoryId: + type: "string" + description: "The ID of the category the product belongs to" + name: + type: "string" + description: "The name of the product" + price: + type: "number" + format: "float" + description: "The price of the product" + description: + type: "string" + description: "The description of the product" + imageUrls: + type: "array" + items: + type: "string" + description: "URL of the product image" + Error: + type: "object" + properties: + message: + type: "string" + description: "Error message" + LoginRequest: + type: "object" + properties: + username: + type: "string" + description: "The username of the user" + password: + type: "string" + description: "The password of the user" + LoginResponse: + type: "object" + properties: + token: + type: "string" + description: "The JWT token for authentication" diff --git a/test/server.test.js b/test/server.test.js index 7ff14c8f..f73153f7 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1,14 +1,273 @@ const chai = require('chai'); const chaiHttp = require('chai-http'); -const server = require('../app/server'); // Adjust the path as needed +const server = require('../app/server'); +const products = require("../initial-data/products.json"); +const brands = require("../initial-data/brands.json"); const should = chai.should(); chai.use(chaiHttp); -// TODO: Write tests for the server -describe('Brands', () => {}); +describe("Brands", () => { + it("should GET all brands on /brands", (done) => { + chai + .request(server) + .get("/brands") + .end((err, res) => { + res.should.have.status(200); + res.body.should.be.a("array"); + res.body.should.deep.equal(brands); + done(); + }); + }); -describe('Login', () => {}); + it("should GET all products for a specific brand on /brands/:id/products", (done) => { + const brandId = "2"; + const returnedProducts = + [ + { + id: "4", + categoryId: "2", + name: "Better glasses", + description: "The best glasses in the world", + price: 1500, + imageUrls: [ + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + ], + }, + { + id: "5", + categoryId: "2", + name: "Glasses", + description: "The most normal glasses in the world", + price: 150, + imageUrls: [ + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + ], + }, + ]; + chai + .request(server) + .get(`/brands/${brandId}/products`) + .end((err, res) => { + res.should.have.status(200); + res.body.should.be.a("array"); + res.body.should.deep.equal(returnedProducts); + done(); + }); + }); -describe('Cart', () => {}); + it("should return 404 if brand is not found on /brands/:id/products", (done) => { + const invalidBrandId = "999"; // Non-existent brand ID + chai + .request(server) + .get(`/brands/${invalidBrandId}/products`) + .end((err, res) => { + res.should.have.status(404); + res.body.should.have.property("error").eql("Brand not found"); + done(); + }); + }); +}); + +describe("Products", () => { + it("should GET all products on /products", (done) => { + chai + .request(server) + .get("/products") + .end((err, res) => { + res.should.have.status(200); + res.body.should.be.a("array"); + res.body.should.deep.equal(products); + done(); + }); + }); +}); + +describe("Login", () => { + it("should POST login and return a token on /login", (done) => { + const credentials = { username: "yellowleopard753", password: "jonjon" }; + chai + .request(server) + .post("/login") + .send(credentials) + .end((err, res) => { + res.should.have.status(200); + res.body.should.have.property("token"); + done(); + }); + }); + + it("should return 400 for missing username or password on /login", (done) => { + const invalidCredentials = { username: "yellowleopard753" }; + chai + .request(server) + .post("/login") + .send(invalidCredentials) + .end((err, res) => { + res.should.have.status(400); + res.body.should.have.property("error").eql("Username and password are required"); + done(); + }); + }); + + it("should return 401 for invalid credentials on /login", (done) => { + const invalidCredentials = { username: "invalid", password: "invalid" }; + chai + .request(server) + .post("/login") + .send(invalidCredentials) + .end((err, res) => { + res.should.have.status(401); + res.body.should.have.property("error").eql("Invalid credentials"); + done(); + }); + }); +}); + +describe("Cart", () => { + let token; + + before((done) => { + const credentials = { username: "yellowleopard753", password: "jonjon" }; + chai + .request(server) + .post("/login") + .send(credentials) + .end((err, res) => { + token = res.body.token; + done(); + }); + }); + + it("should GET the user cart on /me/cart", (done) => { + chai + .request(server) + .get("/me/cart") + .set("Authorization", token) + .end((err, res) => { + res.should.have.status(200); + res.body.should.be.a("array"); + res.body.should.be.eql([]); + done(); + }); + }); + + it("should POST a product to the cart on /me/cart", (done) => { + const product = { + id: "1", + categoryId: "1", + name: "Superglasses", + description: "The best glasses in the world", + price: 150, + imageUrls: [ + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + ] + }; + chai + .request(server) + .post("/me/cart") + .set("Authorization", token) + .send({id: product.id}) + .end((err, res) => { + res.should.have.status(200); + res.body.should.be.a("array"); + productInCart = res.body.find((item) => item.id === product.id); + productInCart.should.exist; + productInCart.should.have.property("quantity").eql(1); + done(); + }); + }); + + it("should update quantity if the product already exists in the cart on /me/cart", (done) => { + const product = { + id: "2", + categoryId: "1", + name: "Black Sunglasses", + description: "The best glasses in the world", + price: 100, + imageUrls: [ + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + "https://image.shutterstock.com/z/stock-photo-yellow-sunglasses-white-backgound-600820286.jpg", + ] + }; + chai + .request(server) + .post("/me/cart") + .set("Authorization", token) + .send({ id: product.id }) + .end((err, res) => { + res.should.have.status(200); + chai + .request(server) + .post("/me/cart") + .set("Authorization", token) + .send({ id: product.id }) + .end((err, res) => { + res.should.have.status(200); + res.body.should.be.a("array"); + const cart = res.body; + const productInCart = cart.find((item) => item.id === product.id); + productInCart.should.exist; + productInCart.should.have.property("quantity").eql(2); + done(); + }); + }); + }); + + it("should DELETE a product from the cart on /me/cart/:productId", (done) => { + const productId = "1"; + chai + .request(server) + .delete(`/me/cart/${productId}`) + .set("Authorization", token) + .end((err, res) => { + res.should.have.status(200); + res.body.should.be.a("array"); + const productInCart = res.body.find((item) => item.id === productId); + should.not.exist(productInCart); + done(); + }); + }); + + it("should return 404 if product is not found in cart on /me/cart/:productId", (done) => { + const invalidProductId = "999"; + chai + .request(server) + .delete(`/me/cart/${invalidProductId}`) + .set("Authorization", token) + .end((err, res) => { + res.should.have.status(404); + res.body.should.have.property("error").eql("Product not found in cart"); + done(); + }); + }); + + it("should POST to update product quantity in cart on /me/cart/:productId", (done) => { + const productId = "2"; + const updatedQuantity = { quantity: 5 }; + chai + .request(server) + .post(`/me/cart/${productId}`) + .set("Authorization", token) + .send(updatedQuantity) + .end((err, res) => { + res.should.have.status(200); + res.body.should.be.a("array"); + const cart = res.body; + const productInCart = cart.find( + (item) => item.id === productId + ); + productInCart.should.exist; + productInCart.should.have.property("quantity").eql(5); + done(); + }); + }); +}); \ No newline at end of file