From f4f7e1819cfc3e0b875cdd0ffe2a06e9a9a89f6b Mon Sep 17 00:00:00 2001 From: Damilola Ogunrotimi <98775983+Fury03@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:58:30 +0000 Subject: [PATCH] feat(analytics): add content performance metrics Track view counts for courses and books and expose comparative analytics across all content: engagement scores, completion rates, interaction rates and time-spent metrics. - engagementCalculator: pure helpers (interaction/completion rates, time spent, composite engagement score) - contentMetricsService: view recording + cross-content aggregation - GET /api/analytics/content-performance (comparative + summary) - GET /api/analytics/content-performance/:type/:id (single item) - Course/book detail endpoints now record views via the service --- app.js | 4 + .../analytics/contentPerformanceController.js | 62 ++++ src/controllers/books/bookController.js | 8 + src/controllers/courses/courseController.js | 12 +- .../analytics/contentPerformanceRoutes.js | 20 ++ .../analytics/contentMetricsService.js | 223 +++++++++++++ src/utils/analytics/engagementCalculator.js | 107 +++++++ test/contentPerformance.test.js | 294 ++++++++++++++++++ 8 files changed, 725 insertions(+), 5 deletions(-) create mode 100644 src/controllers/analytics/contentPerformanceController.js create mode 100644 src/routes/analytics/contentPerformanceRoutes.js create mode 100644 src/services/analytics/contentMetricsService.js create mode 100644 src/utils/analytics/engagementCalculator.js create mode 100644 test/contentPerformance.test.js diff --git a/app.js b/app.js index e174272..2ab23af 100644 --- a/app.js +++ b/app.js @@ -64,6 +64,7 @@ import courseBundleRoutes from "./src/routes/course-bundle.routes.js"; import certificateRoutes from "./src/routes/certificate.routes.js"; import badgeRoutes from "./src/routes/badge.routes.js"; import achievementRoutes from "./src/routes/api/achievements.js"; +import contentPerformanceRoutes from "./src/routes/analytics/contentPerformanceRoutes.js"; import { healthCheck, ping } from "./src/controllers/healthController.js"; import databaseHealthRoutes from "./src/routes/health/database.js"; import databaseMetricsRoutes from "./src/routes/metrics/database.js"; @@ -269,6 +270,9 @@ app.use(versionMiddleware); app.use("/api/v1", generousLimiter, v1Router); app.use("/api/v2", generousLimiter, v2Router); +// Issue #244 — Content performance analytics (views, engagement, completion). +app.use("/api/analytics", generousLimiter, contentPerformanceRoutes); + // Issue #212 — Hashtag trending endpoints. app.use("/api/hashtags", generousLimiter, hashtagRoutes); diff --git a/src/controllers/analytics/contentPerformanceController.js b/src/controllers/analytics/contentPerformanceController.js new file mode 100644 index 0000000..3e52305 --- /dev/null +++ b/src/controllers/analytics/contentPerformanceController.js @@ -0,0 +1,62 @@ +// controllers/analytics/contentPerformanceController.js +import mongoose from "mongoose"; +import logger from "../../config/logger.js"; +import contentMetricsService from "../../services/analytics/contentMetricsService.js"; + +/** + * GET /api/analytics/content-performance + * Comparative analytics across all courses and books: views, engagement, + * completion rates, and a platform-level roll-up. + */ +export const getContentPerformance = async (req, res) => { + try { + const performance = await contentMetricsService.getContentPerformance(); + res.status(200).json({ success: true, ...performance }); + } catch (error) { + logger.error("Failed to compute content performance:", error); + res.status(500).json({ + success: false, + message: "Failed to compute content performance", + }); + } +}; + +/** + * GET /api/analytics/content-performance/:type/:id + * Metrics for a single course or book. + */ +export const getContentMetrics = async (req, res) => { + try { + const { type, id } = req.params; + + if (!["course", "book"].includes(type)) { + return res.status(400).json({ + success: false, + message: "type must be either 'course' or 'book'", + }); + } + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + message: "A valid content id is required", + }); + } + + const metrics = await contentMetricsService.getContentMetrics({ type, id }); + if (!metrics) { + return res.status(404).json({ + success: false, + message: "Content not found", + }); + } + + res.status(200).json({ success: true, metrics }); + } catch (error) { + logger.error("Failed to compute content metrics:", error); + res.status(500).json({ + success: false, + message: "Failed to compute content metrics", + }); + } +}; diff --git a/src/controllers/books/bookController.js b/src/controllers/books/bookController.js index ca71a44..398bc7a 100644 --- a/src/controllers/books/bookController.js +++ b/src/controllers/books/bookController.js @@ -5,6 +5,7 @@ import User from "../../models/User.js"; import cloudinary from "../../utils/cloudinary.js"; import logger from "../../config/logger.js"; import { validateMagicBytes } from "../../utils/fileValidation.js"; +import contentMetricsService from "../../services/analytics/contentMetricsService.js"; import { createNewBookNotification } from "../notificationController.js"; import { APIError, catchAsync } from "../../middlewares/errorHandler.js"; @@ -101,6 +102,13 @@ export const getBook = async (req, res) => { .populate("author", "name avatar bio") .populate("reviews.user", "name avatar"); if (!book) return res.status(404).json({ success: false, message: "Book not found" }); + + // Track a book view for content-performance analytics (issue #244), + // fire-and-forget so a failed metric write never blocks the response. + contentMetricsService + .recordBookView(book._id) + .catch((err) => logger.error("Failed to increment book read count:", err)); + res.json({ success: true, book }); }; diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js index a98ec19..e267944 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -5,6 +5,7 @@ import logger from "../../config/logger.js"; import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; import { getCacheOrSet, CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; import { createNewCourseNotification } from "../notificationController.js"; +import contentMetricsService from "../../services/analytics/contentMetricsService.js"; import { emitEvent, EVENT_TYPES } from "../../services/webhooks/webhookService.js"; import { categoryTaxonomyExists, @@ -124,11 +125,12 @@ export const getCourseById = async (req, res) => { .status(404) .json({ success: false, message: "Course not found" }); - // Track a course view for creator analytics (fire-and-forget so a failed - // metric write never blocks or fails the detail response). - Course.updateOne({ _id: course._id }, { $inc: { views: 1 } }).catch((err) => - logger.error("Failed to increment course view count:", err) - ); + // Track a course view for content-performance analytics (issue #244), + // fire-and-forget so a failed metric write never blocks or fails the + // detail response. + contentMetricsService + .recordCourseView(course._id) + .catch((err) => logger.error("Failed to increment course view count:", err)); res.status(200).json({ success: true, course }); } catch (error) { diff --git a/src/routes/analytics/contentPerformanceRoutes.js b/src/routes/analytics/contentPerformanceRoutes.js new file mode 100644 index 0000000..d687ef5 --- /dev/null +++ b/src/routes/analytics/contentPerformanceRoutes.js @@ -0,0 +1,20 @@ +// routes/analytics/contentPerformanceRoutes.js +// +// Content performance analytics endpoints. Mounted at /api/analytics in +// app.js. All endpoints require authentication (protect). +import express from "express"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { + getContentPerformance, + getContentMetrics, +} from "../../controllers/analytics/contentPerformanceController.js"; + +const router = express.Router(); + +// Comparative analytics across all courses and books. +router.get("/content-performance", protect, getContentPerformance); + +// Metrics for a single item: /content-performance/course/:id | /book/:id +router.get("/content-performance/:type/:id", protect, getContentMetrics); + +export default router; diff --git a/src/services/analytics/contentMetricsService.js b/src/services/analytics/contentMetricsService.js new file mode 100644 index 0000000..0e2e704 --- /dev/null +++ b/src/services/analytics/contentMetricsService.js @@ -0,0 +1,223 @@ +// services/analytics/contentMetricsService.js +// +// Content performance analytics (issue #244): tracks view counts for courses +// and books, and aggregates engagement, completion and interaction metrics +// across all content so creators can see how their work is performing and +// compare items against each other. + +import Course from "../../models/Course.js"; +import Book from "../../models/Book.js"; +import CourseProgress from "../../models/CourseProgress.js"; +import ReadingProgress from "../../models/ReadingProgress.js"; +import { + interactionRate, + completionRate, + avgTimeSpentSeconds, + avgPercentComplete, + engagementScore, +} from "../../utils/analytics/engagementCalculator.js"; + +export class ContentMetricsService { + /** + * Record a course view (fire-and-forget so a failed metric write never + * blocks or fails the detail response). + * + * @param {string} courseId - Course ObjectId. + */ + async recordCourseView(courseId) { + try { + await Course.updateOne({ _id: courseId }, { $inc: { views: 1 } }); + } catch { + // View tracking is best-effort; ignore write failures. + } + } + + /** + * Record a book view (read) — same best-effort semantics as course views. + * + * @param {string} bookId - Book ObjectId. + */ + async recordBookView(bookId) { + try { + await Book.updateOne({ _id: bookId }, { $inc: { readCount: 1 } }); + } catch { + // View tracking is best-effort; ignore write failures. + } + } + + /** + * Build the metrics row for a single course. + * + * @param {object} course - Lean Course document. + * @param {Array} progressDocs - CourseProgress records for the course. + * @returns {object} The metrics row. + */ + _courseRow(course, progressDocs) { + const views = course.views || 0; + const reviews = course.numReviews || 0; + const enrollments = Array.isArray(course.enrolledUsers) + ? course.enrolledUsers.length + : 0; + const completions = progressDocs.filter( + (p) => p.completedAt || Number(p.percentComplete || 0) >= 100 + ).length; + const cr = completionRate(completions, enrollments); + + return { + id: String(course._id), + type: "course", + title: course.title, + category: course.category || "", + views, + reviews, + enrollments, + completions, + completionRate: cr, + interactionRate: interactionRate(reviews, views), + avgTimeSpentSeconds: avgTimeSpentSeconds(progressDocs), + avgPercentComplete: avgPercentComplete(progressDocs), + engagementScore: engagementScore({ + completionRate: cr, + interactionRate: interactionRate(reviews, views), + avgPercentComplete: avgPercentComplete(progressDocs), + }), + }; + } + + /** + * Build the metrics row for a single book. + * + * @param {object} book - Lean Book document. + * @param {Array} progressDocs - ReadingProgress records for the book. + * @returns {object} The metrics row. + */ + _bookRow(book, progressDocs) { + const views = book.readCount || 0; + const reviews = book.numReviews || 0; + // Books have no enrollments; learner depth is the average reading progress + // (ReadingProgress stores the field as `percentage`, not `percentComplete`). + const apc = avgPercentComplete( + progressDocs.map((p) => ({ percentComplete: p.percentage })) + ); + + return { + id: String(book._id), + type: "book", + title: book.title, + category: book.category || "", + views, + reviews, + enrollments: 0, + completions: 0, + completionRate: apc, // proxy: avg reading progress percentage + interactionRate: interactionRate(reviews, views), + avgTimeSpentSeconds: 0, + avgPercentComplete: apc, + engagementScore: engagementScore({ + completionRate: apc, + interactionRate: interactionRate(reviews, views), + avgPercentComplete: apc, + }), + }; + } + + /** + * Comparative analytics across ALL content (courses + books), sorted by + * views, with a platform-level roll-up. + * + * @returns {Promise<{summary: object, content: object[]}>} + */ + async getContentPerformance() { + const [courses, books] = await Promise.all([ + Course.find().lean(), + Book.find().lean(), + ]); + + const [courseProgress, bookProgress] = await Promise.all([ + CourseProgress.find({ course: { $in: courses.map((c) => c._id) } }).lean(), + ReadingProgress.find({ book: { $in: books.map((b) => b._id) } }).lean(), + ]); + + const progressByCourse = this._groupBy(courseProgress, "course"); + const progressByBook = this._groupBy(bookProgress, "book"); + + const content = [ + ...courses.map((course) => + this._courseRow(course, progressByCourse.get(String(course._id)) || []) + ), + ...books.map((book) => + this._bookRow(book, progressByBook.get(String(book._id)) || []) + ), + ].sort((a, b) => b.views - a.views); + + // Course-only completion average for the roll-up (books have no enrollments). + const courseRows = content.filter((row) => row.type === "course"); + const avgCourseCompletion = courseRows.length + ? Math.round( + (courseRows.reduce((sum, r) => sum + r.completionRate, 0) / + courseRows.length) * + 100 + ) / 100 + : 0; + + return { + summary: { + totalContent: content.length, + totalCourses: courseRows.length, + totalBooks: content.length - courseRows.length, + totalViews: content.reduce((sum, r) => sum + r.views, 0), + totalReviews: content.reduce((sum, r) => sum + r.reviews, 0), + avgCompletionRate: avgCourseCompletion, + topByViews: [...content].sort((a, b) => b.views - a.views).slice(0, 3), + topByEngagement: [...content] + .sort((a, b) => b.engagementScore - a.engagementScore) + .slice(0, 3), + }, + content, + }; + } + + /** + * Metrics for a single course or book. + * + * @param {object} params + * @param {"course"|"book"} params.type - Content type. + * @param {string} params.id - Content ObjectId. + * @returns {Promise} The metrics row, or null when not found. + */ + async getContentMetrics({ type, id }) { + if (type === "course") { + const course = await Course.findById(id).lean(); + if (!course) return null; + const progressDocs = await CourseProgress.find({ course: id }).lean(); + return this._courseRow(course, progressDocs); + } + if (type === "book") { + const book = await Book.findById(id).lean(); + if (!book) return null; + const progressDocs = await ReadingProgress.find({ book: id }).lean(); + return this._bookRow(book, progressDocs); + } + return null; + } + + /** + * Group a list of documents by a field, keyed by its string value. + * + * @param {Array} docs - Documents to group. + * @param {string} field - Field name to group by. + * @returns {Map} + */ + _groupBy(docs, field) { + const groups = new Map(); + for (const doc of docs) { + const key = String(doc[field]); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(doc); + } + return groups; + } +} + +export const contentMetricsService = new ContentMetricsService(); +export default contentMetricsService; diff --git a/src/utils/analytics/engagementCalculator.js b/src/utils/analytics/engagementCalculator.js new file mode 100644 index 0000000..884631a --- /dev/null +++ b/src/utils/analytics/engagementCalculator.js @@ -0,0 +1,107 @@ +// utils/analytics/engagementCalculator.js +// +// Pure, side-effect-free helpers that turn raw content signals (views, +// reviews, completion/progress records) into the engagement metrics surfaced +// by the content-performance analytics endpoints. Kept free of database access +// so each rule is independently testable. + +/** + * Round a number to a fixed number of decimal places, guarding against NaN. + * + * @param {number} value - Raw value to round. + * @param {number} [places] - Decimal places to keep (default 2). + * @returns {number} The rounded value, or 0 when the input is not finite. + */ +export const round = (value, places = 2) => { + if (!Number.isFinite(value)) return 0; + const factor = 10 ** places; + return Math.round(value * factor) / factor; +}; + +/** + * Interaction rate: the number of reviews/ratings per view, as a percentage. + * Capped at 100 so a single review on a brand-new item cannot dominate. + * + * @param {number} interactions - Review count (numReviews). + * @param {number} views - View/read count. + * @returns {number} Percentage in the range 0-100. + */ +export const interactionRate = (interactions, views) => { + if (!views || views <= 0 || !interactions || interactions <= 0) return 0; + return Math.min(100, round((interactions / views) * 100)); +}; + +/** + * Completion rate: percentage of enrolled learners who finished the content. + * + * @param {number} completions - Learners who completed. + * @param {number} enrollments - Learners who enrolled. + * @returns {number} Percentage in the range 0-100. + */ +export const completionRate = (completions, enrollments) => { + if (!enrollments || enrollments <= 0) return 0; + return round((completions / enrollments) * 100); +}; + +/** + * Average time spent (seconds) across a set of progress records — e.g. video + * seconds watched (`lastPositionSeconds` on CourseProgress). + * + * @param {Array<{lastPositionSeconds?: number}>} progressDocs - Progress records. + * @returns {number} Average seconds spent, rounded to 2 decimals. + */ +export const avgTimeSpentSeconds = (progressDocs = []) => { + const started = progressDocs.filter( + (p) => Number(p?.lastPositionSeconds || 0) > 0 + ); + if (!started.length) return 0; + const total = started.reduce( + (sum, p) => sum + Number(p.lastPositionSeconds || 0), + 0 + ); + return round(total / started.length); +}; + +/** + * Average completion percentage across a set of progress records (0-100). + * + * @param {Array<{percentComplete?: number}>} progressDocs - Progress records. + * @returns {number} Average percentage, rounded to 2 decimals. + */ +export const avgPercentComplete = (progressDocs = []) => { + if (!progressDocs.length) return 0; + const total = progressDocs.reduce( + (sum, p) => sum + Number(p?.percentComplete || 0), + 0 + ); + return round(total / progressDocs.length); +}; + +/** + * Composite engagement score in the range 0-100, blending how far learners got + * (completion depth), how much the audience interacted (reviews per view) and + * the average completion percentage of started learners. + * + * @param {object} metrics + * @param {number} [metrics.completionRate] - Completion rate percentage (0-100). + * @param {number} [metrics.interactionRate] - Interaction rate percentage (0-100). + * @param {number} [metrics.avgPercentComplete] - Avg learner progress percentage. + * @returns {number} Rounded score in the range 0-100. + */ +export const engagementScore = ({ + completionRate: cr = 0, + interactionRate: ir = 0, + avgPercentComplete: apc = 0, +} = {}) => { + const score = 0.4 * cr + 0.3 * ir + 0.3 * apc; + return Math.min(100, Math.max(0, round(score))); +}; + +export default { + round, + interactionRate, + completionRate, + avgTimeSpentSeconds, + avgPercentComplete, + engagementScore, +}; diff --git a/test/contentPerformance.test.js b/test/contentPerformance.test.js new file mode 100644 index 0000000..9e74052 --- /dev/null +++ b/test/contentPerformance.test.js @@ -0,0 +1,294 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; + +import app from "../app.js"; +import User from "../src/models/User.js"; +import Course from "../src/models/Course.js"; +import Book from "../src/models/Book.js"; +import CourseProgress from "../src/models/CourseProgress.js"; +import ReadingProgress from "../src/models/ReadingProgress.js"; +import { + interactionRate, + completionRate, + avgTimeSpentSeconds, + avgPercentComplete, + engagementScore, +} from "../src/utils/analytics/engagementCalculator.js"; +import { seedUserAndLogin } from "./helpers/testAuth.js"; + +// View increments are fire-and-forget by design, so a test must wait for the +// metric write to land before asserting on the database. +const waitFor = async (fn, timeoutMs = 2000, intervalMs = 25) => { + const start = Date.now(); + for (;;) { + if (await fn()) return; + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +}; + +describe("Engagement calculator utilities (#244)", () => { + it("computes interaction rate as reviews per view, capped at 100", () => { + expect(interactionRate(2, 10)).toBe(20); + expect(interactionRate(5, 2)).toBe(100); // capped + expect(interactionRate(3, 0)).toBe(0); // no views + expect(interactionRate(0, 10)).toBe(0); // no reviews + }); + + it("computes completion rate from completions over enrollments", () => { + expect(completionRate(1, 2)).toBe(50); + expect(completionRate(3, 10)).toBe(30); + expect(completionRate(2, 0)).toBe(0); + }); + + it("averages time spent across progress records", () => { + expect( + avgTimeSpentSeconds([ + { lastPositionSeconds: 100 }, + { lastPositionSeconds: 300 }, + ]) + ).toBe(200); + expect(avgTimeSpentSeconds([])).toBe(0); + }); + + it("averages completion percentage", () => { + expect( + avgPercentComplete([{ percentComplete: 100 }, { percentComplete: 50 }]) + ).toBe(75); + }); + + it("blends completion, interaction and depth into a 0-100 score", () => { + expect( + engagementScore({ + completionRate: 100, + interactionRate: 100, + avgPercentComplete: 100, + }) + ).toBe(100); + expect( + engagementScore({ + completionRate: 50, + interactionRate: 20, + avgPercentComplete: 50, + }) + ).toBe(41); // 0.4*50 + 0.3*20 + 0.3*50 = 41 + expect(engagementScore({})).toBe(0); + }); +}); + +describe("Content performance analytics (#244)", () => { + jest.setTimeout(30000); + let mongoServer; + let author; + let authorToken; + let student; + let studentToken; + let course; + let book; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + const a = await seedUserAndLogin(app, { + name: "Perf Author", + email: "perf-author@example.com", + role: "mentor", + verifiedEducator: true, + }); + author = a.user; + authorToken = a.token; + + const s = await seedUserAndLogin(app, { + name: "Perf Student", + email: "perf-student@example.com", + }); + student = s.user; + studentToken = s.token; + }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + // Seeded users are kept so their login tokens stay valid. + await Promise.all([ + Course.deleteMany({}), + Book.deleteMany({}), + CourseProgress.deleteMany({}), + ReadingProgress.deleteMany({}), + ]); + + course = await Course.create({ + title: "Fiqh of Purification", + description: "Course on ritual purity and prayer.", + category: "Fiqh", + createdBy: author._id, + status: "published", + views: 0, + numReviews: 4, + enrolledUsers: [student._id, author._id], + sections: [ + { + title: "Purification", + order: 1, + lessons: [ + { title: "Wudu", order: 1, durationSeconds: 300 }, + { title: "Ghusl", order: 2, durationSeconds: 300 }, + ], + }, + ], + }); + + book = await Book.create({ + title: "Fortress of the Muslim", + author: author._id, + category: "Duas", + price: 0, + description: "Duas and adhkar.", + image: "https://example.com/cover.jpg", + fileUrl: "https://example.com/book.pdf", + readCount: 10, + numReviews: 2, + }); + + // One learner completed the course, one is halfway. + await CourseProgress.create({ + user: student._id, + course: course._id, + percentComplete: 100, + completedAt: new Date(), + lastPositionSeconds: 600, + }); + await CourseProgress.create({ + user: author._id, + course: course._id, + percentComplete: 50, + lastPositionSeconds: 300, + }); + + // One reader finished the book, one is at 25%. + await ReadingProgress.create({ + user: student._id, + book: book._id, + percentage: 100, + page: 100, + totalPages: 100, + }); + await ReadingProgress.create({ + user: author._id, + book: book._id, + percentage: 25, + page: 25, + totalPages: 100, + }); + }); + + it("requires authentication for the analytics endpoints", async () => { + const res = await request(app).get("/api/analytics/content-performance"); + expect(res.status).toBe(401); + }); + + it("increments course views when a course detail page is fetched", async () => { + const res = await request(app).get(`/api/courses/${course._id}`); + expect(res.status).toBe(200); + + await waitFor(async () => (await Course.findById(course._id)).views === 1); + }); + + it("increments book read counts when a book detail page is fetched", async () => { + const res = await request(app).get(`/api/books/${book._id}`); + expect(res.status).toBe(200); + + await waitFor(async () => (await Book.findById(book._id)).readCount === 11); + }); + + it("returns comparative analytics across courses and books", async () => { + // Fetch both detail pages first so the view counters reflect real usage. + await request(app).get(`/api/courses/${course._id}`); + await request(app).get(`/api/books/${book._id}`); + + const res = await request(app) + .get("/api/analytics/content-performance") + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.summary.totalContent).toBe(2); + expect(res.body.summary.totalViews).toBe(12); // 1 course view + 11 book reads + expect(res.body.content).toHaveLength(2); + + const courseRow = res.body.content.find((c) => c.type === "course"); + expect(courseRow).toMatchObject({ + title: "Fiqh of Purification", + views: 1, + reviews: 4, + enrollments: 2, + completions: 1, + completionRate: 50, + }); + expect(courseRow.interactionRate).toBe(100); // 4 reviews / 1 view, capped + expect(courseRow.avgTimeSpentSeconds).toBe(450); + expect(courseRow.avgPercentComplete).toBe(75); + expect(typeof courseRow.engagementScore).toBe("number"); + + const bookRow = res.body.content.find((c) => c.type === "book"); + expect(bookRow).toMatchObject({ + title: "Fortress of the Muslim", + views: 11, + reviews: 2, + }); + expect(bookRow.interactionRate).toBeCloseTo(18.18, 1); + expect(bookRow.avgPercentComplete).toBe(62.5); + }); + + it("returns metrics for a single course", async () => { + const res = await request(app) + .get(`/api/analytics/content-performance/course/${course._id}`) + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(200); + expect(res.body.metrics.type).toBe("course"); + expect(res.body.metrics.completionRate).toBe(50); + expect(res.body.metrics.enrollments).toBe(2); + }); + + it("returns metrics for a single book", async () => { + const res = await request(app) + .get(`/api/analytics/content-performance/book/${book._id}`) + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(200); + expect(res.body.metrics.type).toBe("book"); + expect(res.body.metrics.views).toBe(10); + expect(res.body.metrics.reviews).toBe(2); + }); + + it("rejects an invalid content type", async () => { + const res = await request(app) + .get(`/api/analytics/content-performance/reel/${course._id}`) + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(400); + }); + + it("returns 404 for a missing course", async () => { + const missing = new mongoose.Types.ObjectId(); + const res = await request(app) + .get(`/api/analytics/content-performance/course/${missing}`) + .set("Authorization", `Bearer ${studentToken}`); + + expect(res.status).toBe(404); + }); +});