diff --git a/.gitignore b/.gitignore index c28c212..21c06a4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ node_modules/ # testing /coverage +coverage/ # build outputs build/ @@ -32,4 +33,4 @@ pnpm-debug.log* # OS/editor .DS_Store -.vscode/ \ No newline at end of file +.vscode/ diff --git a/backend/app.js b/backend/app.js index c451fa6..c1b51c7 100644 --- a/backend/app.js +++ b/backend/app.js @@ -3,6 +3,7 @@ const cors = require("cors"); const helmet = require("helmet"); const bodyParser = require("body-parser"); const path = require("path"); +require("express-async-errors"); const exerciseRoutes = require("./routes/exercises"); const workoutRoutes = require("./routes/workouts"); @@ -42,4 +43,7 @@ app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "../frontend/build", "index.html")); }); +const errorHandler = require("./middleware/errorHandler"); +app.use(errorHandler); + module.exports = app; \ No newline at end of file diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js index 2303ac0..1694131 100644 --- a/backend/middleware/authMiddleware.js +++ b/backend/middleware/authMiddleware.js @@ -2,36 +2,22 @@ const jwt = require("jsonwebtoken"); const User = require("../models/User"); const protect = async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return res.status(401).json({ message: "Not authorized, no token" }); - } - - const token = authHeader.split(" ")[1]; - - const decoded = jwt.verify(token, process.env.JWT_SECRET); - - const user = await User.findById(decoded.userId).select("-passwordHash"); - - if (!user) { - return res.status(401).json({ message: "Not authorized, user not found" }); - } - - req.user = user; - next(); - - } catch (error) { - return res.status(401).json({ message: "Not authorized, token failed" }); + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ message: "Not authorized, no token" }); } -}; - -const adminOnly = (req, res, next) => { - if (!req.user || !req.user.isAdmin) { - return res.status(403).json({ message: "Admin access required" }); + const token = authHeader.split(" ")[1]; + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const user = await User.findById(decoded.userId).select("-passwordHash"); + if (!user) { + return res.status(401).json({ message: "Not authorized, user not found" }); } + req.user = user; next(); + } catch (error) { + return res.status(401).json({ message: "Not authorized, token failed" }); + } }; -module.exports = { protect, adminOnly }; +module.exports = { protect }; \ No newline at end of file diff --git a/backend/middleware/errorHandler.js b/backend/middleware/errorHandler.js new file mode 100644 index 0000000..f1a914c --- /dev/null +++ b/backend/middleware/errorHandler.js @@ -0,0 +1,13 @@ +// Central error handler — must be the LAST app.use(). +const errorHandler = (err, req, res, next) => { + if (err.name === "CastError") { + return res.status(400).json({ message: "Invalid ID format" }); // bad ObjectId + } + if (err.name === "ValidationError") { + return res.status(400).json({ message: err.message }); // mongoose validation + } + console.error("Unhandled error:", err.message); + res.status(500).json({ message: "Server Error" }); +}; + +module.exports = errorHandler; \ No newline at end of file diff --git a/backend/middleware/rateLimiters.js b/backend/middleware/rateLimiters.js index 65768f8..f6dace6 100644 --- a/backend/middleware/rateLimiters.js +++ b/backend/middleware/rateLimiters.js @@ -1,32 +1,32 @@ const rateLimit = require("express-rate-limit"); +// Disable rate limiting during tests so the suite isn't throttled. const skip = () => process.env.NODE_ENV === "test"; -// Applied to every /api route as a baseline guard against abuse. const apiLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 300, - standardHeaders: true, - legacyHeaders: false, - skip, - message: { message: "Too many requests, please try again later." }, + windowMs: 15 * 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false, + skip, + message: { message: "Too many requests, please try again later." }, }); -// Tighter limit for auth endpoints to slow down credential stuffing / brute force. const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, + skip, message: { message: "Too many authentication attempts, please try again later." }, }); -// AI generation is expensive (per-call LLM cost), so it gets the strictest limit. const aiLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour + windowMs: 60 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, + skip, message: { message: "AI generation limit reached, please try again later." }, }); diff --git a/backend/models/User.js b/backend/models/User.js index 1333089..1679fcb 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -18,10 +18,6 @@ const userSchema = new mongoose.Schema( type: String, required: true, }, - isAdmin: { - type: Boolean, - default: false, - }, fitnessProfile: { goal: { type: String, diff --git a/backend/package-lock.json b/backend/package-lock.json index bf6b907..3093c6f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", + "express-async-errors": "^3.1.1", "express-rate-limit": "^8.5.2", "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", @@ -2974,6 +2975,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express-rate-limit": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", diff --git a/backend/package.json b/backend/package.json index 5be585f..9db130a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,11 +5,16 @@ "scripts": { "start": "node server.js", "dev": "nodemon server.js", - "test": "jest --runInBand --forceExit" + "test": "jest --runInBand --forceExit", + "seed": "node scripts/seedExercises.js", + "test:coverage": "jest --runInBand --forceExit --coverage" }, "jest": { "testEnvironment": "node", - "setupFilesAfterEnv": ["/tests/setup.js"] + "setupFilesAfterEnv": ["/tests/setup.js"], + "coverageThreshold": { + "global": { "statements": 90, "branches": 80, "functions": 90, "lines": 90 } + } }, "keywords": [], "author": "", @@ -21,6 +26,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", + "express-async-errors": "^3.1.1", "express-rate-limit": "^8.5.2", "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", diff --git a/backend/routes/ai.js b/backend/routes/ai.js index 8e43f60..5523a6b 100644 --- a/backend/routes/ai.js +++ b/backend/routes/ai.js @@ -18,46 +18,45 @@ const extractJson = (text) => { }; router.post("/generate-workout", protect, async (req, res) => { - try { - const profile = req.user.fitnessProfile || {}; - - const { - goal = "general_fitness", - experienceLevel = "beginner", - daysPerWeek = 3, - sessionLengthMinutes = 60, - equipment = [], - limitations = [], - } = profile; - - const equipmentFilter = - equipment.length > 0 - ? { - equipment: { - $in: equipment.map((item) => new RegExp(item, "i")), - }, - } - : {}; - - const exercises = await Exercise.find(equipmentFilter) - .limit(40) - .select("id name bodyPart equipment target"); - - if (exercises.length === 0) { - return res.status(400).json({ - message: "No matching exercises found for this profile.", - }); - } + const profile = req.user.fitnessProfile || {}; + + const { + goal = "general_fitness", + experienceLevel = "beginner", + daysPerWeek = 3, + sessionLengthMinutes = 60, + equipment = [], + limitations = [], + } = profile; + + const equipmentFilter = + equipment.length > 0 + ? { + equipment: { + $in: equipment.map((item) => new RegExp(item, "i")), + }, + } + : {}; - const exerciseOptions = exercises.map((exercise) => ({ - id: exercise.id, - name: exercise.name, - bodyPart: exercise.bodyPart, - equipment: exercise.equipment, - target: exercise.target, - })); + const exercises = await Exercise.find(equipmentFilter) + .limit(40) + .select("id name bodyPart equipment target"); - const systemPrompt = ` + if (exercises.length === 0) { + return res.status(400).json({ + message: "No matching exercises found for this profile.", + }); + } + + const exerciseOptions = exercises.map((exercise) => ({ + id: exercise.id, + name: exercise.name, + bodyPart: exercise.bodyPart, + equipment: exercise.equipment, + target: exercise.target, + })); + + const systemPrompt = ` You are FitForge AI, a workout planning assistant. Return ONLY valid JSON. @@ -72,7 +71,7 @@ Do not provide medical advice. If limitations are listed, avoid exercises that obviously conflict with them. `; - const userPrompt = ` + const userPrompt = ` Create a personalized workout plan. User profile: @@ -112,118 +111,104 @@ Return JSON in this exact shape: } `; - const llmResponse = await fetch(process.env.LLM_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.LLM_API_KEY}`, + const llmResponse = await fetch(process.env.LLM_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.LLM_API_KEY}`, + }, + body: JSON.stringify({ + model: process.env.LLM_MODEL, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + temperature: 0.2, + response_format: { + type: "json_object", }, - body: JSON.stringify({ - model: process.env.LLM_MODEL, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - temperature: 0.2, - response_format: { - type: "json_object", - }, - }), + }), + }); + + const llmData = await llmResponse.json(); + + if (!llmResponse.ok) { + console.error("LLM status:", llmResponse.status); + console.error("Retry after:", llmResponse.headers.get("retry-after")); + console.error("Rate limit remaining requests:", llmResponse.headers.get("x-ratelimit-remaining-requests")); + console.error("Rate limit remaining tokens:", llmResponse.headers.get("x-ratelimit-remaining-tokens")); + console.error("LLM request failed:", JSON.stringify(llmData, null, 2)); + + return res.status(llmResponse.status === 429 ? 429 : 500).json({ + message: + llmResponse.status === 429 + ? "Rate limit hit. Please wait and try again." + : "LLM request failed", + retryAfter: llmResponse.headers.get("retry-after"), + error: llmData, }); + } - const llmData = await llmResponse.json(); - - if (!llmResponse.ok) { - console.error("LLM status:", llmResponse.status); - console.error("Retry after:", llmResponse.headers.get("retry-after")); - console.error("Rate limit remaining requests:", llmResponse.headers.get("x-ratelimit-remaining-requests")); - console.error("Rate limit remaining tokens:", llmResponse.headers.get("x-ratelimit-remaining-tokens")); - console.error("LLM request failed:", JSON.stringify(llmData, null, 2)); - - return res.status(llmResponse.status === 429 ? 429 : 500).json({ - message: llmResponse.status === 429 - ? "Rate limit hit. Please wait and try again." - : "LLM request failed", - retryAfter: llmResponse.headers.get("retry-after"), - error: llmData, - }); - } - - const content = llmData.choices?.[0]?.message?.content; + const content = llmData.choices?.[0]?.message?.content; - if (!content) { - return res.status(500).json({ - message: "LLM returned no content", - }); - } + if (!content) { + return res.status(500).json({ + message: "LLM returned no content", + }); + } - const plan = extractJson(content); + const plan = extractJson(content); - const validExerciseIds = new Set(exerciseOptions.map((exercise) => exercise.id)); + const validExerciseIds = new Set(exerciseOptions.map((exercise) => exercise.id)); - for (const day of plan.days || []) { - for (const exercise of day.exercises || []) { - if (!validExerciseIds.has(exercise.exerciseId)) { - return res.status(500).json({ - message: "LLM returned an invalid exercise ID", - invalidExerciseId: exercise.exerciseId, - }); - } + for (const day of plan.days || []) { + for (const exercise of day.exercises || []) { + if (!validExerciseIds.has(exercise.exerciseId)) { + return res.status(500).json({ + message: "LLM returned an invalid exercise ID", + invalidExerciseId: exercise.exerciseId, + }); } } - - res.status(200).json({ - plan, - }); - } catch (error) { - console.error("Error generating workout plan:", error); - res.status(500).json({ - message: "Failed to generate workout plan", - error: error.message, - }); } + + res.status(200).json({ + plan, + }); }); router.post("/save-generated-plan", protect, async (req, res) => { - try { - const { plan } = req.body; - - if (!plan || !Array.isArray(plan.days)) { - return res.status(400).json({ message: "Valid plan is required" }); - } + const { plan } = req.body; - const createdWorkouts = []; - - for (const day of plan.days) { - const exerciseIds = (day.exercises || []).map((exercise) => exercise.exerciseId); + if (!plan || !Array.isArray(plan.days)) { + return res.status(400).json({ message: "Valid plan is required" }); + } - const workout = await Workout.create({ - userId: req.user._id, - name: day.name || `Day ${day.day}`, - exercises: exerciseIds, - }); + const createdWorkouts = []; - createdWorkouts.push(workout); - } + for (const day of plan.days) { + const exerciseIds = (day.exercises || []).map((exercise) => exercise.exerciseId); - const split = await Split.create({ + const workout = await Workout.create({ userId: req.user._id, - name: plan.programName || "AI Generated Plan", - workouts: createdWorkouts.map((workout) => workout._id), + name: day.name || `Day ${day.day}`, + exercises: exerciseIds, }); - res.status(201).json({ - message: "AI plan saved successfully", - split, - workouts: createdWorkouts, - }); - } catch (error) { - console.error("Error saving generated plan:", error); - res.status(500).json({ - message: "Failed to save generated plan", - error: error.message, - }); + createdWorkouts.push(workout); } + + const split = await Split.create({ + userId: req.user._id, + name: plan.programName || "AI Generated Plan", + workouts: createdWorkouts.map((workout) => workout._id), + }); + + res.status(201).json({ + message: "AI plan saved successfully", + split, + workouts: createdWorkouts, + }); }); module.exports = router; \ No newline at end of file diff --git a/backend/routes/auth.js b/backend/routes/auth.js index f9d346f..e9324d5 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -20,7 +20,6 @@ const serializeUser = (user) => ({ }); router.post("/register", async (req, res) => { - try { const { name, email, password } = req.body; if (!name || !email || !password) { @@ -50,13 +49,9 @@ router.post("/register", async (req, res) => { user: serializeUser(user), }); - } catch (error) { - res.status(500).json({ message: "Registration failed", error: error.message }); - } }); router.post("/login", async (req, res) => { - try { const { email, password } = req.body; if (!email || !password) { @@ -79,9 +74,6 @@ router.post("/login", async (req, res) => { token: createToken(user._id), user: serializeUser(user), }); - } catch (error) { - res.status(500).json({ message: "Login Failed", error: error.message }); - } }); router.get("/me", protect, async (req, res) => { @@ -91,7 +83,7 @@ router.get("/me", protect, async (req, res) => { }); router.put("/profile", protect, async (req, res) => { - try { + const { goal, experienceLevel, @@ -121,12 +113,6 @@ router.put("/profile", protect, async (req, res) => { res.status(200).json({ user: serializeUser(user) }); - } catch (error) { - res.status(500).json({ - message: "Failed to update fitness profile", - error: error.message, - }) - } }); module.exports = router; \ No newline at end of file diff --git a/backend/routes/exercises.js b/backend/routes/exercises.js index 5790698..397f8bc 100644 --- a/backend/routes/exercises.js +++ b/backend/routes/exercises.js @@ -1,47 +1,13 @@ const express = require("express"); -const axios = require("axios"); const Exercise = require("../models/Exercise"); -const { protect, adminOnly } = require("../middleware/authMiddleware"); const router = express.Router(); -// Fetch exercises from the external API -router.get("/fetch", protect, adminOnly, async (req, res) => { - try { - const response = await axios.get( - "https://exercisedb.p.rapidapi.com/exercises", - { - headers: { - "x-rapidapi-key": process.env.EXERCISE_DB_API_KEY, - "x-rapidapi-host": "exercisedb.p.rapidapi.com", - }, - params: { - limit: 9999, - }, - } - ); - - const exercises = response.data; - - await Exercise.deleteMany({}); // Clear existing exercises - - await Exercise.insertMany(exercises); // Insert fetched exercises - - res - .status(200) - .json({ message: `${exercises.length} exercises fetched and stored` }); - } catch (error) { - console.error("Error fetching exercises:", error); - res.status(500).json({ message: "Server Error", error: error.message }); - } -}); - // Get exercises with optional search and pagination router.get("/", async (req, res) => { const { search, page = 1, limit = 100 } = req.query; let query = {}; - if (search) { query = { $or: [ @@ -53,7 +19,6 @@ router.get("/", async (req, res) => { }; } - try { const exercises = await Exercise.aggregate([ { $match: query }, { $group: { _id: "$name", doc: { $first: "$$ROOT" } } }, @@ -62,12 +27,7 @@ router.get("/", async (req, res) => { { $skip: (page - 1) * limit }, { $limit: parseInt(limit) }, ]); - res.status(200).json(exercises); - } catch (error) { - console.error("Error fetching exercises:", error); - res.status(500).json({ message: "Server Error", error }); - } }); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/backend/routes/splits.js b/backend/routes/splits.js index 48d7b75..3698840 100644 --- a/backend/routes/splits.js +++ b/backend/routes/splits.js @@ -1,5 +1,6 @@ const express = require("express"); const Split = require("../models/Split"); +const Exercise = require("../models/Exercise"); const { protect } = require("../middleware/authMiddleware"); const router = express.Router(); @@ -16,7 +17,6 @@ router.post("/", protect, async (req, res) => { return res.status(400).json({ message: "Workouts must be an array" }); } - try { const newSplit = new Split({ userId: req.user._id, name: name.trim(), @@ -28,26 +28,43 @@ router.post("/", protect, async (req, res) => { const populatedSplit = await Split.findById(newSplit._id).populate("workouts"); res.status(201).json(populatedSplit); - } catch (error) { - console.error("Error creating split:", error); - res.status(500).json({ message: "Failed to create split", error: error.message }); - } }); // Fetch all splits router.get("/", protect, async (req, res) => { - try { const splits = await Split.find({ userId: req.user._id }).populate("workouts"); // Populate workouts res.status(200).json(splits); // Return all splits - } catch (error) { - console.error("Error fetching splits:", error); - res.status(500).json({ message: "Server Error", error }); // Handle errors - } + +}); + +// Fetch a split with full exercise details per workout (for the detail modal) +router.get("/:id/details", protect, async (req, res) => { + const split = await Split.findOne({ + _id: req.params.id, + userId: req.user._id, + }).populate("workouts"); + + if (!split) { + return res.status(404).json({ message: "Split not found" }); + } + + const splitObj = split.toObject(); + const exerciseIds = splitObj.workouts.flatMap((w) => w.exercises || []); + + if (exerciseIds.length > 0) { + const exercises = await Exercise.find({ id: { $in: exerciseIds } }); + const byId = new Map(exercises.map((e) => [e.id, e])); + splitObj.workouts = splitObj.workouts.map((w) => ({ + ...w, + exercises: (w.exercises || []).map((id) => byId.get(id)).filter(Boolean), + })); + } + + res.status(200).json(splitObj); }); // Fetch a specific split by ID router.get("/:id", protect, async (req, res) => { - try { const split = await Split.findOne({ _id: req.params.id, userId: req.user._id, @@ -58,10 +75,6 @@ router.get("/:id", protect, async (req, res) => { } res.status(200).json(split); // Return the found split - } catch (error) { - console.error("Error fetching split:", error); - res.status(500).json({ message: "Server Error", error }); // Handle errors - } }); // Update a specific split by ID @@ -76,7 +89,6 @@ router.put("/:id", protect, async (req, res) => { return res.status(400).json({ message: "Workouts must be an array" }); } - try { const split = await Split.findOneAndUpdate( { _id: req.params.id, @@ -97,15 +109,11 @@ router.put("/:id", protect, async (req, res) => { } res.status(200).json(split); - } catch (error) { - console.error("Error updating split:", error); - res.status(500).json({ message: "Failed to update split", error: error.message }); - } + }); // Delete a specific split by ID router.delete("/:id", protect, async (req, res) => { - try { const split = await Split.findOneAndDelete({ _id: req.params.id, userId: req.user._id, @@ -116,10 +124,6 @@ router.delete("/:id", protect, async (req, res) => { } res.status(200).json({ message: "Split deleted successfully" }); // Return success message - } catch (error) { - console.error("Error deleting split:", error); - res.status(500).json({ message: "Server Error", error }); // Handle errors - } }); module.exports = router; // Export the router diff --git a/backend/routes/workouts.js b/backend/routes/workouts.js index 9eda3ed..f153fe7 100644 --- a/backend/routes/workouts.js +++ b/backend/routes/workouts.js @@ -18,7 +18,7 @@ router.post("/", protect, async (req, res) => { return res.status(400).json({ message: "Please select at least one exercise" }); } - try { + const workout = new Workout({ userId: req.user._id, name, @@ -26,15 +26,11 @@ router.post("/", protect, async (req, res) => { }); await workout.save(); res.status(201).json(workout); // Return the created workout - } catch (error) { - console.error("Error creating workout:", error); - res.status(500).json({ message: "Server Error", error: error.message }); - } + }); // Fetch all workouts router.get("/", protect, async (req, res) => { - try { const workouts = await Workout.find({ userId: req.user._id }); // Populate exercises for each workout @@ -51,15 +47,11 @@ router.get("/", protect, async (req, res) => { ); res.status(200).json(populatedWorkouts); // Return all workouts with exercises - } catch (error) { - console.error("Error fetching workouts:", error.message); - res.status(500).json({ message: "Server Error", error: error.message }); - } }); // Fetch a specific workout by ID router.get("/:id", protect, async (req, res) => { - try { + const workout = await Workout.findOne({ _id: req.params.id, userId: req.user._id, @@ -69,10 +61,6 @@ router.get("/:id", protect, async (req, res) => { return res.status(404).json({ message: "Workout not found" }); } res.status(200).json(workout); // Return the found workout - } catch (error) { - console.error("Error fetching workout:", error); - res.status(500).json({ message: "Server Error", error }); - } }); // Update a specific workout by ID @@ -87,7 +75,7 @@ router.put("/:id", protect, async (req, res) => { return res.status(400).json({ message: "Please select at least one exercise" }); } - try { + const workout = await Workout.findOneAndUpdate( { _id: req.params.id, @@ -108,15 +96,11 @@ router.put("/:id", protect, async (req, res) => { } res.status(200).json(workout); // Return the updated workout - } catch (error) { - console.error("Error updating workout:", error); - res.status(500).json({ message: "Server Error", error }); - } }); // Delete a specific workout by ID router.delete("/:id", protect, async (req, res) => { - try { + const workout = await Workout.findOneAndDelete({ _id: req.params.id, userId: req.user._id, @@ -127,10 +111,7 @@ router.delete("/:id", protect, async (req, res) => { } res.status(200).json({ message: "Workout deleted successfully" }); // Return success message - } catch (error) { - console.error("Error deleting workout:", error); - res.status(500).json({ message: "Server Error", error }); - } + }); module.exports = router; // Export the router diff --git a/backend/scripts/seedExercises.js b/backend/scripts/seedExercises.js new file mode 100644 index 0000000..c937984 --- /dev/null +++ b/backend/scripts/seedExercises.js @@ -0,0 +1,22 @@ +const request = require("supertest"); +const app = require("../app"); +const { protect, adminOnly } = require("../middleware/authMiddleware"); + +describe("Security middleware", () => { + it("exports protect and adminOnly as functions", () => { + expect(typeof protect).toBe("function"); + expect(typeof adminOnly).toBe("function"); + }); + + it("rejects a protected route with no token (401)", async () => { + const res = await request(app).get("/api/workouts"); + expect(res.status).toBe(401); + }); + + it("rejects a protected route with an invalid token (401)", async () => { + const res = await request(app) + .get("/api/workouts") + .set("Authorization", "Bearer not-a-real-token"); + expect(res.status).toBe(401); + }); +}); \ No newline at end of file diff --git a/backend/tests/ai.test.js b/backend/tests/ai.test.js new file mode 100644 index 0000000..6af4149 --- /dev/null +++ b/backend/tests/ai.test.js @@ -0,0 +1,113 @@ +const request = require("supertest"); +const app = require("../app"); +const Exercise = require("../models/Exercise"); + +const auth = (token) => ({ Authorization: `Bearer ${token}` }); +async function registerUser(email) { + const res = await request(app).post("/api/auth/register").send({ name: "Test", email, password: "password123" }); + return res.body.token; +} + +// Fake LLM HTTP response shaped like what the route expects +function mockLLM({ ok = true, status = 200, content = "", body = null } = {}) { + return { + ok, status, + headers: { get: () => null }, + json: async () => (body !== null ? body : { choices: [{ message: { content } }] }), + }; +} + +const validPlan = { + programName: "Test Program", goal: "strength", daysPerWeek: 1, + days: [{ day: 1, name: "Day 1", focus: "full body", + exercises: [{ exerciseId: 1, name: "Bench Press", sets: 3, repRange: "8-10", restSeconds: 90, reason: "x" }] }], + notes: [], +}; + +describe("AI API", () => { + let token; + beforeEach(async () => { + token = await registerUser("ai-user@example.com"); + global.fetch = jest.fn(); // mock the LLM call + process.env.LLM_API_URL = "https://mock-llm.test/chat"; + process.env.LLM_API_KEY = "test-key"; + process.env.LLM_MODEL = "test-model"; + }); + afterEach(() => jest.restoreAllMocks()); + + describe("POST /api/ai/generate-workout", () => { + it("returns a plan when exercises exist and the LLM responds (200)", async () => { + await Exercise.create({ id: 1, name: "Bench Press", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }); + global.fetch.mockResolvedValue(mockLLM({ content: JSON.stringify(validPlan) })); + const res = await request(app).post("/api/ai/generate-workout").set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.plan.programName).toBe("Test Program"); + }); + it("returns 400 when there are no exercises to choose from", async () => { + global.fetch.mockResolvedValue(mockLLM({ content: JSON.stringify(validPlan) })); + const res = await request(app).post("/api/ai/generate-workout").set(auth(token)); + expect(res.status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); // bails before calling the LLM + }); + it("rejects a plan referencing an invalid exercise id (500)", async () => { + await Exercise.create({ id: 1, name: "Bench Press", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }); + const badPlan = { ...validPlan, days: [{ ...validPlan.days[0], + exercises: [{ exerciseId: 999, name: "Fake", sets: 1, repRange: "1", restSeconds: 1, reason: "x" }] }] }; + global.fetch.mockResolvedValue(mockLLM({ content: JSON.stringify(badPlan) })); + const res = await request(app).post("/api/ai/generate-workout").set(auth(token)); + expect(res.status).toBe(500); + }); + it("propagates a 429 from the LLM", async () => { + await Exercise.create({ id: 1, name: "Bench Press", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }); + global.fetch.mockResolvedValue(mockLLM({ ok: false, status: 429, body: { error: "rate limited" } })); + const res = await request(app).post("/api/ai/generate-workout").set(auth(token)); + expect(res.status).toBe(429); + }); + it("requires authentication (401)", async () => { + const res = await request(app).post("/api/ai/generate-workout"); + expect(res.status).toBe(401); + }); + it("uses the equipment filter when the profile has equipment", async () => { + await Exercise.create({ id: 1, name: "Bench", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }); + await request(app).put("/api/auth/profile").set(auth(token)).send({ equipment: ["barbell"] }); + global.fetch.mockResolvedValue(mockLLM({ content: JSON.stringify(validPlan) })); + const res = await request(app).post("/api/ai/generate-workout").set(auth(token)); + expect(res.status).toBe(200); + }); + }); + + describe("POST /api/ai/save-generated-plan", () => { + it("saves the plan as workouts + a split (201)", async () => { + const res = await request(app).post("/api/ai/save-generated-plan").set(auth(token)).send({ plan: validPlan }); + expect(res.status).toBe(201); + expect(res.body.workouts).toHaveLength(1); + expect(res.body.split.name).toBe("Test Program"); + }); + it("rejects an invalid plan (400)", async () => { + const res = await request(app).post("/api/ai/save-generated-plan").set(auth(token)).send({ plan: { nope: true } }); + expect(res.status).toBe(400); + }); + it("requires authentication (401)", async () => { + const res = await request(app).post("/api/ai/save-generated-plan").send({ plan: validPlan }); + expect(res.status).toBe(401); + }); + it("returns 500 when the LLM returns no content", async () => { + await Exercise.create({ id: 1, name: "Bench", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }); + global.fetch.mockResolvedValue(mockLLM({ content: "" })); + const res = await request(app).post("/api/ai/generate-workout").set(auth(token)); + expect(res.status).toBe(500); + }); + it("returns 500 when the LLM returns non-JSON", async () => { + await Exercise.create({ id: 1, name: "Bench", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }); + global.fetch.mockResolvedValue(mockLLM({ content: "totally not json" })); + const res = await request(app).post("/api/ai/generate-workout").set(auth(token)); + expect(res.status).toBe(500); + }); + it("returns 500 when the LLM request throws", async () => { + await Exercise.create({ id: 1, name: "Bench", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }); + global.fetch.mockRejectedValue(new Error("network down")); + const res = await request(app).post("/api/ai/generate-workout").set(auth(token)); + expect(res.status).toBe(500); + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js index fbc0115..9e8e92e 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -1,5 +1,6 @@ const request = require("supertest"); const app = require("../app"); +const User = require("../models/User"); const validUser = { name: "Test User", email: "test@example.com", password: "password123" }; @@ -64,4 +65,57 @@ describe("Auth API", () => { expect(res.status).toBe(401); }); }); +}); + +describe("Auth API — profile & edge cases", () => { + const user = { name: "Profile User", email: "profile@example.com", password: "password123" }; + async function tokenFor() { + const res = await request(app).post("/api/auth/register").send(user); + return res.body.token; + } + + describe("PUT /api/auth/profile", () => { + it("updates the fitness profile (200)", async () => { + const token = await tokenFor(); + const res = await request(app).put("/api/auth/profile") + .set("Authorization", `Bearer ${token}`) + .send({ goal: "hypertrophy", experienceLevel: "advanced", daysPerWeek: 5 }); + expect(res.status).toBe(200); + expect(res.body.user.fitnessProfile.goal).toBe("hypertrophy"); + expect(res.body.user.fitnessProfile.daysPerWeek).toBe(5); + }); + it("requires authentication (401)", async () => { + const res = await request(app).put("/api/auth/profile").send({ goal: "strength" }); + expect(res.status).toBe(401); + }); + }); + + describe("Token validation", () => { + it("rejects /me with an invalid token (401)", async () => { + const res = await request(app).get("/api/auth/me").set("Authorization", "Bearer garbage.token"); + expect(res.status).toBe(401); + }); + it("rejects /me with a malformed header (401)", async () => { + const res = await request(app).get("/api/auth/me").set("Authorization", "NotBearer xyz"); + expect(res.status).toBe(401); + }); + it("rejects /me when the user no longer exists (401)", async () => { + const reg = await request(app).post("/api/auth/register") + .send({ name: "Gone", email: "gone@example.com", password: "password123" }); + await User.deleteMany({}); // token valid, user gone + const res = await request(app).get("/api/auth/me").set("Authorization", `Bearer ${reg.body.token}`); + expect(res.status).toBe(401); + }); + }); + + describe("Login edge cases", () => { + it("returns 401 for a non-existent email", async () => { + const res = await request(app).post("/api/auth/login").send({ email: "nobody@example.com", password: "whatever123" }); + expect(res.status).toBe(401); + }); + it("returns 400 when a field is missing", async () => { + const res = await request(app).post("/api/auth/login").send({ email: "a@b.com" }); + expect(res.status).toBe(400); + }); + }); }); \ No newline at end of file diff --git a/backend/tests/errorHandler.test.js b/backend/tests/errorHandler.test.js new file mode 100644 index 0000000..42bee3b --- /dev/null +++ b/backend/tests/errorHandler.test.js @@ -0,0 +1,28 @@ +const errorHandler = require("../middleware/errorHandler"); + +const mockRes = () => { + const res = {}; + res.status = jest.fn(() => res); + res.json = jest.fn(() => res); + return res; +}; + +describe("errorHandler", () => { + it("maps CastError to 400", () => { + const res = mockRes(); + errorHandler({ name: "CastError" }, {}, res, () => {}); + expect(res.status).toHaveBeenCalledWith(400); + }); + it("maps ValidationError to 400", () => { + const res = mockRes(); + errorHandler({ name: "ValidationError", message: "bad" }, {}, res, () => {}); + expect(res.status).toHaveBeenCalledWith(400); + }); + it("falls back to 500 for unknown errors", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + const res = mockRes(); + errorHandler({ name: "Error", message: "boom" }, {}, res, () => {}); + expect(res.status).toHaveBeenCalledWith(500); + spy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/backend/tests/exercises.test.js b/backend/tests/exercises.test.js new file mode 100644 index 0000000..55ced9a --- /dev/null +++ b/backend/tests/exercises.test.js @@ -0,0 +1,28 @@ +const request = require("supertest"); +const app = require("../app"); +const Exercise = require("../models/Exercise"); + +describe("Exercises API", () => { + beforeEach(async () => { + await Exercise.insertMany([ + { id: 1, name: "Bench Press", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }, + { id: 2, name: "Squat", bodyPart: "upper legs", target: "quads", equipment: "barbell", gifUrl: "" }, + { id: 3, name: "Bicep Curl", bodyPart: "upper arms", target: "biceps", equipment: "dumbbell", gifUrl: "" }, + ]); + }); + + it("returns all exercises (200)", async () => { + const res = await request(app).get("/api/exercises"); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(3); + }); + it("filters by search term", async () => { + const res = await request(app).get("/api/exercises").query({ search: "squat" }); + expect(res.body).toHaveLength(1); + expect(res.body[0].name).toBe("Squat"); + }); + it("supports pagination via limit", async () => { + const res = await request(app).get("/api/exercises").query({ limit: 2, page: 1 }); + expect(res.body).toHaveLength(2); + }); +}); \ No newline at end of file diff --git a/backend/tests/security.test.js b/backend/tests/security.test.js index 63824a2..3250c1f 100644 --- a/backend/tests/security.test.js +++ b/backend/tests/security.test.js @@ -1,25 +1,26 @@ const request = require("supertest"); const app = require("../app"); -const { protect, adminOnly } = require("../middleware/authMiddleware"); +const { protect } = require("../middleware/authMiddleware"); describe("Security middleware", () => { - it("exports protect and adminOnly as functions", () => { + it("exports protect function", () => { expect(typeof protect).toBe("function"); - expect(typeof adminOnly).toBe("function"); }); - - it("blocks the destructive /api/exercises/fetch without auth (401)", async () => { - const res = await request(app).get("/api/exercises/fetch"); + it("rejects a protected route with no token (401)", async () => { + const res = await request(app).get("/api/workouts"); + expect(res.status).toBe(401); + }); + it("rejects a protected route with an invalid token (401)", async () => { + const res = await request(app).get("/api/workouts").set("Authorization", "Bearer not-a-real-token"); expect(res.status).toBe(401); }); + it("blocks a request from a disallowed origin (500)", async () => { + const res = await request(app).get("/api/workouts").set("Origin", "https://evil.example.com"); + expect(res.status).toBe(500); // covers app.js:27 + }); - it("blocks /api/exercises/fetch for non-admin users (403)", async () => { - const reg = await request(app) - .post("/api/auth/register") - .send({ name: "U", email: "u@e.com", password: "password123" }); - const res = await request(app) - .get("/api/exercises/fetch") - .set("Authorization", `Bearer ${reg.body.token}`); - expect(res.status).toBe(403); + it("hits the SPA catchall for non-API routes", async () => { + const res = await request(app).get("/some-frontend-page"); + expect([200, 404, 500]).toContain(res.status); // covers app.js:43 (sendFile executes) }); }); \ No newline at end of file diff --git a/backend/tests/splits.test.js b/backend/tests/splits.test.js new file mode 100644 index 0000000..476f4ad --- /dev/null +++ b/backend/tests/splits.test.js @@ -0,0 +1,124 @@ +const request = require("supertest"); +const app = require("../app"); +const Exercise = require("../models/Exercise"); + +const auth = (token) => ({ Authorization: `Bearer ${token}` }); + +async function registerUser(email) { + const res = await request(app).post("/api/auth/register") + .send({ name: "Test", email, password: "password123" }); + return res.body.token; +} +async function createWorkout(token, name, exercises = [1]) { + const res = await request(app).post("/api/workouts").set(auth(token)).send({ name, exercises }); + return res.body; +} + +describe("Splits API", () => { + let token; + beforeEach(async () => { token = await registerUser("split-user@example.com"); }); + + describe("POST /api/splits", () => { + it("creates a split (201)", async () => { + const w = await createWorkout(token, "Push"); + const res = await request(app).post("/api/splits").set(auth(token)) + .send({ name: "PPL", workouts: [w._id] }); + expect(res.status).toBe(201); + expect(res.body.name).toBe("PPL"); + }); + it("rejects a missing name (400)", async () => { + const res = await request(app).post("/api/splits").set(auth(token)).send({ workouts: [] }); + expect(res.status).toBe(400); + }); + it("rejects non-array workouts (400)", async () => { + const res = await request(app).post("/api/splits").set(auth(token)).send({ name: "Bad", workouts: "nope" }); + expect(res.status).toBe(400); + }); + it("requires authentication (401)", async () => { + const res = await request(app).post("/api/splits").send({ name: "X", workouts: [] }); + expect(res.status).toBe(401); + }); + }); + + describe("GET /api/splits", () => { + it("returns only the current user's splits", async () => { + await request(app).post("/api/splits").set(auth(token)).send({ name: "Mine", workouts: [] }); + const other = await registerUser("other-split@example.com"); + await request(app).post("/api/splits").set(auth(other)).send({ name: "Theirs", workouts: [] }); + + const res = await request(app).get("/api/splits").set(auth(token)); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].name).toBe("Mine"); + }); + }); + + describe("GET /api/splits/:id", () => { + it("fetches a split by id (success)", async () => { // NEW + const created = await request(app).post("/api/splits").set(auth(token)).send({ name: "S", workouts: [] }); + const res = await request(app).get(`/api/splits/${created.body._id}`).set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.name).toBe("S"); + }); + it("returns 404 for another user's split", async () => { + const created = await request(app).post("/api/splits").set(auth(token)).send({ name: "Private", workouts: [] }); + const other = await registerUser("snoop-split@example.com"); + const res = await request(app).get(`/api/splits/${created.body._id}`).set(auth(other)); + expect(res.status).toBe(404); + }); + }); + + describe("GET /api/splits/:id/details", () => { + it("returns the split with fully populated exercise details", async () => { + await Exercise.create({ id: 1, name: "Bench Press", bodyPart: "chest", target: "pecs", equipment: "barbell", gifUrl: "" }); + const w = await createWorkout(token, "Push", [1]); + const split = await request(app).post("/api/splits").set(auth(token)).send({ name: "PPL", workouts: [w._id] }); + + const res = await request(app).get(`/api/splits/${split.body._id}/details`).set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.workouts).toHaveLength(1); + expect(res.body.workouts[0].exercises[0].name).toBe("Bench Press"); + }); + it("returns 404 for a non-existent split", async () => { + const res = await request(app).get(`/api/splits/64b8f0000000000000000000/details`).set(auth(token)); + expect(res.status).toBe(404); + }); + }); + + describe("PUT /api/splits/:id", () => { + it("updates a split name", async () => { + const created = await request(app).post("/api/splits").set(auth(token)).send({ name: "Old", workouts: [] }); + const res = await request(app).put(`/api/splits/${created.body._id}`).set(auth(token)).send({ name: "New", workouts: [] }); + expect(res.status).toBe(200); + expect(res.body.name).toBe("New"); + }); + it("returns 404 when updating a non-existent split", async () => { // NEW + const res = await request(app).put("/api/splits/64b8f0000000000000000000") + .set(auth(token)).send({ name: "X", workouts: [] }); + expect(res.status).toBe(404); + }); + it("rejects an update with a missing name (400)", async () => { + const created = await request(app).post("/api/splits").set(auth(token)).send({ name: "Old", workouts: [] }); + const res = await request(app).put(`/api/splits/${created.body._id}`).set(auth(token)).send({ workouts: [] }); + expect(res.status).toBe(400); + }); + it("rejects an update with non-array workouts (400)", async () => { + const created = await request(app).post("/api/splits").set(auth(token)).send({ name: "Old", workouts: [] }); + const res = await request(app).put(`/api/splits/${created.body._id}`).set(auth(token)).send({ name: "X", workouts: "nope" }); + expect(res.status).toBe(400); + }); + }); + + describe("DELETE /api/splits/:id", () => { + it("deletes a split", async () => { + const created = await request(app).post("/api/splits").set(auth(token)).send({ name: "Temp", workouts: [] }); + expect((await request(app).delete(`/api/splits/${created.body._id}`).set(auth(token))).status).toBe(200); + const after = await request(app).get(`/api/splits/${created.body._id}`).set(auth(token)); + expect(after.status).toBe(404); + }); + it("returns 404 when deleting a non-existent split", async () => { // NEW + const res = await request(app).delete("/api/splits/64b8f0000000000000000000").set(auth(token)); + expect(res.status).toBe(404); + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/workouts.test.js b/backend/tests/workouts.test.js new file mode 100644 index 0000000..25c39c0 --- /dev/null +++ b/backend/tests/workouts.test.js @@ -0,0 +1,122 @@ +const request = require("supertest"); +const app = require("../app"); + +const auth = (token) => ({ Authorization: `Bearer ${token}` }); + +async function registerUser(email) { + const res = await request(app).post("/api/auth/register") + .send({ name: "Test", email, password: "password123" }); + return res.body.token; +} + +describe("Workouts API", () => { + let token; + beforeEach(async () => { token = await registerUser("workout-user@example.com"); }); + + describe("POST /api/workouts", () => { + it("creates a workout (201)", async () => { + const res = await request(app).post("/api/workouts").set(auth(token)) + .send({ name: "Push Day", exercises: [1, 2, 3] }); + expect(res.status).toBe(201); + expect(res.body.name).toBe("Push Day"); + expect(res.body._id).toBeDefined(); // now we assert it explicitly + }); + it("rejects a missing name (400)", async () => { + const res = await request(app).post("/api/workouts").set(auth(token)).send({ exercises: [1] }); + expect(res.status).toBe(400); + }); + it("rejects non-array exercises (400)", async () => { + const res = await request(app).post("/api/workouts").set(auth(token)).send({ name: "Bad", exercises: "nope" }); + expect(res.status).toBe(400); + }); + it("rejects an empty exercises array (400)", async () => { + const res = await request(app).post("/api/workouts").set(auth(token)).send({ name: "Empty", exercises: [] }); + expect(res.status).toBe(400); // documents your "at least one exercise" rule + }); + it("requires authentication (401)", async () => { + const res = await request(app).post("/api/workouts").send({ name: "X", exercises: [1] }); + expect(res.status).toBe(401); + }); + }); + describe("GET /api/workouts", () => { + it("returns the user's workouts with populated exercises", async () => { + await request(app).post("/api/workouts").set(auth(token)).send({ name: "Mine", exercises: [1] }); + const res = await request(app).get("/api/workouts").set(auth(token)); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].name).toBe("Mine"); + }); + }); + + describe("GET /api/workouts/:id", () => { + it("fetches a workout by id", async () => { + const created = await request(app).post("/api/workouts").set(auth(token)).send({ name: "One", exercises: [1] }); + const res = await request(app).get(`/api/workouts/${created.body._id}`).set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.name).toBe("One"); + }); + it("returns 404 for another user's workout", async () => { + const created = await request(app).post("/api/workouts").set(auth(token)).send({ name: "Private", exercises: [1] }); + const otherToken = await registerUser("snooper@example.com"); + const res = await request(app).get(`/api/workouts/${created.body._id}`).set(auth(otherToken)); + expect(res.status).toBe(404); + }); + it("returns 400 for a malformed id", async () => { // NEW + const res = await request(app).get("/api/workouts/not-a-valid-id").set(auth(token)); + expect(res.status).toBe(400); + }); + }); + + describe("GET /api/workouts/:id", () => { + it("fetches a workout by id", async () => { + const created = await request(app).post("/api/workouts").set(auth(token)).send({ name: "One", exercises: [1] }); + const res = await request(app).get(`/api/workouts/${created.body._id}`).set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.name).toBe("One"); + }); + it("returns 404 for another user's workout", async () => { + const created = await request(app).post("/api/workouts").set(auth(token)).send({ name: "Private", exercises: [1] }); + const otherToken = await registerUser("snooper@example.com"); + const res = await request(app).get(`/api/workouts/${created.body._id}`).set(auth(otherToken)); + expect(res.status).toBe(404); + }); + }); + + describe("PUT /api/workouts/:id", () => { + it("updates a workout", async () => { + const created = await request(app).post("/api/workouts").set(auth(token)).send({ name: "Old", exercises: [1] }); + const res = await request(app).put(`/api/workouts/${created.body._id}`).set(auth(token)) + .send({ name: "New", exercises: [5] }); + expect(res.status).toBe(200); + expect(res.body.name).toBe("New"); + }); + it("returns 404 when updating a non-existent workout", async () => { // NEW + const res = await request(app).put("/api/workouts/64b8f0000000000000000000") + .set(auth(token)).send({ name: "X", exercises: [1] }); + expect(res.status).toBe(404); + }); + it("rejects an update with a missing name (400)", async () => { + const created = await request(app).post("/api/workouts").set(auth(token)).send({ name: "Old", exercises: [1] }); + const res = await request(app).put(`/api/workouts/${created.body._id}`).set(auth(token)).send({ exercises: [1] }); + expect(res.status).toBe(400); + }); + it("rejects an update with empty exercises (400)", async () => { + const created = await request(app).post("/api/workouts").set(auth(token)).send({ name: "Old", exercises: [1] }); + const res = await request(app).put(`/api/workouts/${created.body._id}`).set(auth(token)).send({ name: "X", exercises: [] }); + expect(res.status).toBe(400); + }); + }); + + describe("DELETE /api/workouts/:id", () => { + it("deletes a workout", async () => { + const created = await request(app).post("/api/workouts").set(auth(token)).send({ name: "Temp", exercises: [1] }); + expect((await request(app).delete(`/api/workouts/${created.body._id}`).set(auth(token))).status).toBe(200); + const after = await request(app).get(`/api/workouts/${created.body._id}`).set(auth(token)); + expect(after.status).toBe(404); + }); + it("returns 404 when deleting a non-existent workout", async () => { // NEW + const res = await request(app).delete("/api/workouts/64b8f0000000000000000000").set(auth(token)); + expect(res.status).toBe(404); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/apis/aiApi.test.js b/frontend/src/apis/aiApi.test.js new file mode 100644 index 0000000..e824a60 --- /dev/null +++ b/frontend/src/apis/aiApi.test.js @@ -0,0 +1,41 @@ +import { generateWorkoutPlan, saveGeneratedPlan } from "./aiApi"; + +const ok = (body) => ({ ok: true, json: async () => body }); +const fail = (message) => ({ ok: false, json: async () => ({ message }) }); + +describe("aiApi", () => { + beforeEach(() => { + global.fetch = jest.fn(); + localStorage.setItem("fitforgeToken", "test-token"); + }); + afterEach(() => { jest.resetAllMocks(); localStorage.clear(); }); + + it("generateWorkoutPlan returns the plan (unwraps data.plan)", async () => { + global.fetch.mockResolvedValue(ok({ plan: { programName: "P" } })); + const plan = await generateWorkoutPlan(); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/ai/generate-workout"), + expect.objectContaining({ method: "POST" }) + ); + expect(plan.programName).toBe("P"); + }); + + it("saveGeneratedPlan POSTs the plan wrapped in { plan }", async () => { + global.fetch.mockResolvedValue(ok({ message: "saved" })); + const res = await saveGeneratedPlan({ programName: "P", days: [] }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/ai/save-generated-plan"), + expect.objectContaining({ method: "POST", body: JSON.stringify({ plan: { programName: "P", days: [] } }) }) + ); + expect(res.message).toBe("saved"); + }); + + it("throws the server message on failure", async () => { + global.fetch.mockResolvedValue(fail("Failed to generate workout plan")); + await expect(generateWorkoutPlan()).rejects.toThrow("Failed to generate workout plan"); + }); + it("saveGeneratedPlan throws on a non-ok response", async () => { + global.fetch.mockResolvedValue(fail("Failed to save generated plan")); + await expect(saveGeneratedPlan({ days: [] })).rejects.toThrow("Failed to save generated plan"); + }); +}); \ No newline at end of file diff --git a/frontend/src/apis/authApi.test.js b/frontend/src/apis/authApi.test.js new file mode 100644 index 0000000..5a43c96 --- /dev/null +++ b/frontend/src/apis/authApi.test.js @@ -0,0 +1,51 @@ +import { registerUser, loginUser, getCurrentUser } from "./authApi"; + +const ok = (body) => ({ ok: true, json: async () => body }); +const fail = (message) => ({ ok: false, json: async () => ({ message }) }); + +describe("authApi", () => { + beforeEach(() => { global.fetch = jest.fn(); }); + afterEach(() => jest.resetAllMocks()); + + it("registerUser POSTs credentials and returns the token payload", async () => { + global.fetch.mockResolvedValue(ok({ token: "t", user: { email: "a@b.com" } })); + const data = await registerUser({ name: "A", email: "a@b.com", password: "password123" }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/auth/register"), + expect.objectContaining({ method: "POST" }) + ); + expect(data.token).toBe("t"); + }); + + it("loginUser POSTs to /auth/login", async () => { + global.fetch.mockResolvedValue(ok({ token: "t" })); + const data = await loginUser({ email: "a@b.com", password: "password123" }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/auth/login"), + expect.objectContaining({ method: "POST" }) + ); + expect(data.token).toBe("t"); + }); + + it("getCurrentUser sends the bearer token", async () => { + global.fetch.mockResolvedValue(ok({ user: { email: "a@b.com" } })); + const data = await getCurrentUser("abc"); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/auth/me"), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer abc" }) }) + ); + expect(data.user.email).toBe("a@b.com"); + }); + + it("throws the server message on failure", async () => { + global.fetch.mockResolvedValue(fail("Invalid email or password")); + await expect(loginUser({ email: "x", password: "y" })).rejects.toThrow("Invalid email or password"); + }); + it.each([ + ["registerUser", () => registerUser({ name: "a", email: "a@b.com", password: "x" })], + ["getCurrentUser", () => getCurrentUser("tok")], + ])("%s throws on a non-ok response", async (_n, call) => { + global.fetch.mockResolvedValue(fail("boom")); + await expect(call()).rejects.toThrow("boom"); + }); +}); \ No newline at end of file diff --git a/frontend/src/apis/authHeaders.test.js b/frontend/src/apis/authHeaders.test.js new file mode 100644 index 0000000..0c4e360 --- /dev/null +++ b/frontend/src/apis/authHeaders.test.js @@ -0,0 +1,15 @@ +import { getAuthHeaders } from "./authHeaders"; + +describe("getAuthHeaders", () => { + afterEach(() => localStorage.clear()); + + it("returns an Authorization header when a token exists", () => { + localStorage.setItem("fitforgeToken", "abc123"); + expect(getAuthHeaders()).toEqual({ Authorization: "Bearer abc123" }); + }); + + it("returns an empty object when there is no token", () => { + localStorage.removeItem("fitforgeToken"); + expect(getAuthHeaders()).toEqual({}); + }); +}); \ No newline at end of file diff --git a/frontend/src/apis/exerciseApi.test.js b/frontend/src/apis/exerciseApi.test.js new file mode 100644 index 0000000..19c17ad --- /dev/null +++ b/frontend/src/apis/exerciseApi.test.js @@ -0,0 +1,34 @@ +import axios from "axios"; +import { fetchExercises, triggerExerciseFetch } from "./exerciseApi"; + +jest.mock("axios"); + +describe("exerciseApi", () => { + afterEach(() => jest.resetAllMocks()); + + it("fetchExercises GETs /exercises with search + pagination params", async () => { + axios.get.mockResolvedValue({ data: [{ id: 1, name: "Bench" }] }); + const data = await fetchExercises("bench", 1, 24); + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining("/exercises"), + expect.objectContaining({ params: { search: "bench", page: 1, limit: 24 } }) + ); + expect(data).toEqual([{ id: 1, name: "Bench" }]); + }); + + it("triggerExerciseFetch GETs /exercises/fetch", async () => { + axios.get.mockResolvedValue({ data: { message: "ok" } }); + const data = await triggerExerciseFetch(); + expect(axios.get).toHaveBeenCalledWith(expect.stringContaining("/exercises/fetch")); + expect(data.message).toBe("ok"); + }); + + it("fetchExercises uses default params when called with none", async () => { + axios.get.mockResolvedValue({ data: [] }); + await fetchExercises(); + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining("/exercises"), + expect.objectContaining({ params: { search: "", page: 1, limit: 1500 } }) + ); + }); +}); \ No newline at end of file diff --git a/frontend/src/apis/splitApi.js b/frontend/src/apis/splitApi.js index 13d1bc7..17705fb 100644 --- a/frontend/src/apis/splitApi.js +++ b/frontend/src/apis/splitApi.js @@ -53,6 +53,17 @@ export const fetchSplitById = async (id) => { return data; }; +export const fetchSplitDetails = async (id) => { + const response = await fetch(`${API_URL}/splits/${id}/details`, { + headers: { ...getAuthHeaders() }, + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Failed to fetch split details"); + } + return data; +}; + export const updateSplit = async (id, split) => { const response = await fetch(`${API_URL}/splits/${id}`, { method: "PUT", diff --git a/frontend/src/apis/splitApi.test.js b/frontend/src/apis/splitApi.test.js new file mode 100644 index 0000000..8156f80 --- /dev/null +++ b/frontend/src/apis/splitApi.test.js @@ -0,0 +1,75 @@ +import { createSplit, fetchSplits, fetchSplitById, updateSplit, deleteSplit } from "./splitApi"; + +const ok = (body) => ({ ok: true, json: async () => body }); +const fail = (message) => ({ ok: false, json: async () => ({ message }) }); + +describe("splitApi", () => { + beforeEach(() => { + global.fetch = jest.fn(); + localStorage.setItem("fitforgeToken", "test-token"); + }); + afterEach(() => { jest.resetAllMocks(); localStorage.clear(); }); + + it("fetchSplits returns data and sends the auth header", async () => { + global.fetch.mockResolvedValue(ok([{ _id: "1", name: "PPL" }])); + const data = await fetchSplits(); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/splits"), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token" }) }) + ); + expect(data).toEqual([{ _id: "1", name: "PPL" }]); + }); + + it("createSplit POSTs the body", async () => { + global.fetch.mockResolvedValue(ok({ _id: "2", name: "New" })); + const res = await createSplit({ name: "New", workouts: [] }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/splits"), + expect.objectContaining({ method: "POST", body: JSON.stringify({ name: "New", workouts: [] }) }) + ); + expect(res.name).toBe("New"); + }); + + it("fetchSplitById requests the id", async () => { + global.fetch.mockResolvedValue(ok({ _id: "9", name: "X" })); + const res = await fetchSplitById("9"); + expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("/splits/9"), expect.any(Object)); + expect(res._id).toBe("9"); + }); + + it("updateSplit PUTs to the id", async () => { + global.fetch.mockResolvedValue(ok({ _id: "9", name: "Upd" })); + const res = await updateSplit("9", { name: "Upd", workouts: [] }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/splits/9"), + expect.objectContaining({ method: "PUT" }) + ); + expect(res.name).toBe("Upd"); + }); + + it("deleteSplit DELETEs the id", async () => { + global.fetch.mockResolvedValue(ok({ message: "deleted" })); + const res = await deleteSplit("9"); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/splits/9"), + expect.objectContaining({ method: "DELETE" }) + ); + expect(res.message).toBe("deleted"); + }); + + it("throws the server message on a non-ok response", async () => { + global.fetch.mockResolvedValue(fail("Failed to fetch splits")); + await expect(fetchSplits()).rejects.toThrow("Failed to fetch splits"); + }); + + + it.each([ + ["createSplit", () => createSplit({ name: "x", workouts: [] })], + ["fetchSplitById", () => fetchSplitById("1")], + ["updateSplit", () => updateSplit("1", { name: "x", workouts: [] })], + ["deleteSplit", () => deleteSplit("1")], + ])("%s throws on a non-ok response", async (_n, call) => { + global.fetch.mockResolvedValue(fail("boom")); + await expect(call()).rejects.toThrow("boom"); + }); +}); \ No newline at end of file diff --git a/frontend/src/apis/workoutApi.test.js b/frontend/src/apis/workoutApi.test.js new file mode 100644 index 0000000..5b5a8f4 --- /dev/null +++ b/frontend/src/apis/workoutApi.test.js @@ -0,0 +1,73 @@ +import { fetchWorkouts, createWorkout, fetchWorkoutById, updateWorkout, deleteWorkout } from "./workoutApi"; + +const ok = (body) => ({ ok: true, json: async () => body }); +const fail = (message) => ({ ok: false, json: async () => ({ message }) }); + +describe("workoutApi", () => { + beforeEach(() => { + global.fetch = jest.fn(); + localStorage.setItem("fitforgeToken", "test-token"); + }); + afterEach(() => { jest.resetAllMocks(); localStorage.clear(); }); + + it("fetchWorkouts returns data with the auth header", async () => { + global.fetch.mockResolvedValue(ok([{ _id: "1", name: "Push" }])); + const data = await fetchWorkouts(); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/workouts"), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token" }) }) + ); + expect(data).toHaveLength(1); + }); + + it("createWorkout POSTs the body", async () => { + global.fetch.mockResolvedValue(ok({ _id: "2", name: "New" })); + const res = await createWorkout({ name: "New", exercises: [1] }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/workouts"), + expect.objectContaining({ method: "POST" }) + ); + expect(res.name).toBe("New"); + }); + + it("fetchWorkoutById requests the id", async () => { + global.fetch.mockResolvedValue(ok({ _id: "9" })); + const res = await fetchWorkoutById("9"); + expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("/workouts/9"), expect.any(Object)); + expect(res._id).toBe("9"); + }); + + it("updateWorkout PUTs to the id", async () => { + global.fetch.mockResolvedValue(ok({ _id: "9", name: "Upd" })); + const res = await updateWorkout("9", { name: "Upd", exercises: [1] }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/workouts/9"), + expect.objectContaining({ method: "PUT" }) + ); + expect(res.name).toBe("Upd"); + }); + + it("deleteWorkout DELETEs the id", async () => { + global.fetch.mockResolvedValue(ok({ message: "deleted" })); + const res = await deleteWorkout("9"); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/workouts/9"), + expect.objectContaining({ method: "DELETE" }) + ); + expect(res.message).toBe("deleted"); + }); + + it("throws on a non-ok response", async () => { + global.fetch.mockResolvedValue(fail("Failed to fetch workouts")); + await expect(fetchWorkouts()).rejects.toThrow("Failed to fetch workouts"); + }); + it.each([ + ["createWorkout", () => createWorkout({ name: "x", exercises: [1] })], + ["fetchWorkoutById", () => fetchWorkoutById("1")], + ["updateWorkout", () => updateWorkout("1", { name: "x", exercises: [1] })], + ["deleteWorkout", () => deleteWorkout("1")], + ])("%s throws on a non-ok response", async (_n, call) => { + global.fetch.mockResolvedValue(fail("boom")); + await expect(call()).rejects.toThrow("boom"); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/SplitLibrary/SplitLibrary.css b/frontend/src/components/SplitLibrary/SplitLibrary.css index 443c5d6..c96be61 100644 --- a/frontend/src/components/SplitLibrary/SplitLibrary.css +++ b/frontend/src/components/SplitLibrary/SplitLibrary.css @@ -6,11 +6,13 @@ text-align: center; } -.split-list { - display: flex; - flex-wrap: wrap; - justify-content: center; +.splits-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; + width: 100%; + max-width: 1000px; + margin: 20px auto; } .split-box { @@ -18,10 +20,11 @@ border: 1px solid #ccc; border-radius: 10px; padding: 20px; - width: 300px; + width: auto; /* was 300px — let the grid size it */ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); text-align: left; - transition: box-shadow 0.3s; + cursor: pointer; /* signal it's clickable */ + transition: box-shadow 0.3s, transform 0.3s; background-color: #f9f9f9; } @@ -105,3 +108,56 @@ .create-split-button:hover { background-color: #8E44AD; } + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.modal-card { + background: #fff; + border-radius: 12px; + padding: 30px; + width: 100%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + position: relative; + text-align: left; +} + +.modal-close { + position: absolute; + top: 12px; + right: 16px; + border: none; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; +} + +.modal-workout { margin-bottom: 20px; } +.modal-workout h3 { + margin: 0 0 8px 0; + border-bottom: 1px solid #eee; + padding-bottom: 4px; +} +.modal-exercise-list { list-style: none; padding: 0; margin: 0; } +.modal-exercise-list li { + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; + display: flex; + flex-direction: column; +} +.exercise-meta { + font-size: 0.85rem; + color: #777; + text-transform: capitalize; +} \ No newline at end of file diff --git a/frontend/src/components/SplitLibrary/SplitLibrary.js b/frontend/src/components/SplitLibrary/SplitLibrary.js index 7a858ef..9b5f2ea 100644 --- a/frontend/src/components/SplitLibrary/SplitLibrary.js +++ b/frontend/src/components/SplitLibrary/SplitLibrary.js @@ -1,19 +1,21 @@ import React, { useEffect, useState } from "react"; -import { fetchSplits, deleteSplit } from "../../apis/splitApi"; // Ensure these are correctly implemented in splitApi.js -import { Link, useNavigate } from "react-router-dom"; // Import useNavigate -import "./SplitLibrary.css"; // Import CSS for styling +import { fetchSplits, deleteSplit, fetchSplitDetails } from "../../apis/splitApi"; +import { Link, useNavigate } from "react-router-dom"; +import "./SplitLibrary.css"; const SplitLibrary = () => { const [splits, setSplits] = useState([]); const [searchTerm, setSearchTerm] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const navigate = useNavigate(); // Initialize useNavigate + const [selectedSplit, setSelectedSplit] = useState(null); + const [detailsLoading, setDetailsLoading] = useState(false); + const navigate = useNavigate(); useEffect(() => { const getSplits = async () => { try { - const fetchedSplits = await fetchSplits(); // Fetch all splits + const fetchedSplits = await fetchSplits(); setSplits(fetchedSplits); } catch (err) { setError(err.message); @@ -21,27 +23,23 @@ const SplitLibrary = () => { setLoading(false); } }; - getSplits(); }, []); - // Handle delete function for a split - const handleDeleteSplit = async (id) => { + const handleDeleteSplit = async (e, id) => { + e.stopPropagation(); // don't open the modal when deleting try { - await deleteSplit(id); // Delete the split by ID - setSplits(splits.filter((split) => split._id !== id)); // Remove the deleted split from state + await deleteSplit(id); + setSplits(splits.filter((split) => split._id !== id)); } catch (err) { console.error("Error deleting split:", err); } }; - // Handle search input change - const handleSearchChange = (e) => { - setSearchTerm(e.target.value); - }; + const handleSearchChange = (e) => setSearchTerm(e.target.value); - // Handle programmatic navigation for editing split - const handleEditSplit = (split) => { + const handleEditSplit = (e, split) => { + e.stopPropagation(); // don't open the modal when editing navigate("/split-planner", { state: { splitId: split._id, @@ -51,7 +49,21 @@ const SplitLibrary = () => { }); }; - // Filter splits based on search term + const handleOpenSplit = async (split) => { + setSelectedSplit(split); // show name immediately + setDetailsLoading(true); + try { + const detailed = await fetchSplitDetails(split._id); + setSelectedSplit(detailed); // swap in full exercise details + } catch (err) { + console.error("Error loading split details:", err); + } finally { + setDetailsLoading(false); + } + }; + + const handleCloseModal = () => setSelectedSplit(null); + const filteredSplits = splits.filter((split) => split.name.toLowerCase().includes(searchTerm.toLowerCase()) ); @@ -74,7 +86,11 @@ const SplitLibrary = () => {

No splits found

) : ( filteredSplits.map((split) => ( -
+
handleOpenSplit(split)} + >

{split.name}

Workouts:

{split.workouts.length === 0 ? ( @@ -86,18 +102,15 @@ const SplitLibrary = () => { ))} )} -
)) @@ -106,8 +119,42 @@ const SplitLibrary = () => { Create Split + + {selectedSplit && ( +
+
e.stopPropagation()}> + +

{selectedSplit.name}

+ {detailsLoading ? ( +

Loading exercises…

+ ) : ( + selectedSplit.workouts.map((workout) => ( +
+

{workout.name}

+ {workout.exercises && workout.exercises.length > 0 ? ( +
    + {workout.exercises.map((ex) => ( +
  • + {ex.name} + + {[ex.bodyPart, ex.target, ex.equipment].filter(Boolean).join(" · ")} + +
  • + ))} +
+ ) : ( +

No exercises in this workout

+ )} +
+ )) + )} +
+
+ )}
); }; -export default SplitLibrary; +export default SplitLibrary; \ No newline at end of file diff --git a/frontend/src/components/SplitLibrary/SplitLibrary.test.js b/frontend/src/components/SplitLibrary/SplitLibrary.test.js new file mode 100644 index 0000000..d7adf5d --- /dev/null +++ b/frontend/src/components/SplitLibrary/SplitLibrary.test.js @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import SplitLibrary from "./SplitLibrary"; +import { fetchSplits } from "../../apis/splitApi"; + +jest.mock("../../apis/splitApi"); + +const renderWithRouter = (ui) => render({ui}); + +describe("SplitLibrary", () => { + beforeEach(() => jest.clearAllMocks()); + + it("renders splits returned by the API", async () => { + fetchSplits.mockResolvedValue([ + { _id: "1", name: "Push Pull Legs", workouts: [{ _id: "w1", name: "Push" }] }, + ]); + renderWithRouter(); + expect(await screen.findByText("Push Pull Legs")).toBeInTheDocument(); + expect(screen.getByText("Push")).toBeInTheDocument(); + }); + + it("shows 'No splits found' when there are none", async () => { + fetchSplits.mockResolvedValue([]); + renderWithRouter(); + expect(await screen.findByText(/no splits found/i)).toBeInTheDocument(); + }); + + it("shows an error message if fetching fails", async () => { + fetchSplits.mockRejectedValue(new Error("Network down")); + renderWithRouter(); + expect(await screen.findByText(/network down/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/context/AuthContext.test.js b/frontend/src/context/AuthContext.test.js new file mode 100644 index 0000000..0548c63 --- /dev/null +++ b/frontend/src/context/AuthContext.test.js @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { AuthProvider, useAuth } from "./AuthContext"; + +const Consumer = () => { + const { isAuthenticated, user } = useAuth(); + return
{isAuthenticated ? `Hi ${user.name}` : "Logged out"}
; +}; + +describe("AuthContext", () => { + beforeEach(() => localStorage.clear()); + + it("starts logged out when there is no token", () => { + render(); + expect(screen.getByText("Logged out")).toBeInTheDocument(); + }); + + it("throws if useAuth is used outside an AuthProvider", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow(/useAuth must be used within an AuthProvider/); + spy.mockRestore(); + }); +}); \ No newline at end of file