From f8eecfce9737a294758e509bd48001b6b6a1a0de Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 26 Jul 2026 15:57:46 +0100 Subject: [PATCH] feat(analytics): add revenue analytics endpoints --- app.js | 2 + .../analytics/analyticsController.js | 278 ++++++++++++++++++ src/controllers/courses/courseController.js | 6 +- src/controllers/userController.js | 34 +++ src/models/Course.js | 17 ++ src/models/CourseProgress.js | 40 +++ src/routes/analytics/analyticsRoutes.js | 22 ++ src/routes/courses/courseRoutes.js | 15 + src/routes/userRoutes.js | 2 + test/analytics.test.js | 166 +++++++++++ 10 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 src/controllers/analytics/analyticsController.js create mode 100644 src/models/CourseProgress.js create mode 100644 src/routes/analytics/analyticsRoutes.js create mode 100644 test/analytics.test.js diff --git a/app.js b/app.js index 52651c5d..44935a75 100644 --- a/app.js +++ b/app.js @@ -47,6 +47,7 @@ import payoutRoutes from "./src/routes/payoutRoutes.js"; import uploadRoutes from "./src/routes/uploadRoutes.js"; import jobsRoutes from "./src/routes/jobsRoutes.js"; import wellKnownRoutes from "./src/routes/wellKnownRoutes.js"; +import analyticsRoutes from "./src/routes/analytics/analyticsRoutes.js"; handleUncaughtException(); validateEnv(); @@ -187,6 +188,7 @@ app.use("/api/stellar/payment", stellarPaymentRoutes); app.use("/api/stellar/donation", stellarDonationRoutes); app.use("/api/payouts", payoutRoutes); app.use("/api/uploads", uploadRoutes); +app.use("/api/analytics", analyticsRoutes); app.use("/admin/jobs", jobsRoutes); // ====================== diff --git a/src/controllers/analytics/analyticsController.js b/src/controllers/analytics/analyticsController.js new file mode 100644 index 00000000..26e6de64 --- /dev/null +++ b/src/controllers/analytics/analyticsController.js @@ -0,0 +1,278 @@ +import mongoose from "mongoose"; +import Transaction from "../../models/Transaction.js"; +import CourseProgress from "../../models/CourseProgress.js"; +import Course from "../../models/Course.js"; +import User from "../../models/User.js"; +import logger from "../../config/logger.js"; + +const toDecimalString = (value) => { + if (value === null || value === undefined || value === "") return "0"; + if (typeof value === "string") return value; + return value.toString(); +}; + +const buildDateFilter = ({ from, to }) => { + const filter = {}; + if (from || to) { + filter.confirmedAt = {}; + if (from) filter.confirmedAt.$gte = new Date(from); + if (to) filter.confirmedAt.$lte = new Date(to); + } + return filter; +}; + +export const getEducatorEarnings = async (req, res) => { + try { + const educatorId = req.user._id; + const { from, to, limit = 10 } = req.query; + + const match = { + creator: educatorId, + status: "confirmed", + ...buildDateFilter({ from, to }), + }; + + const [summary, items] = await Promise.all([ + Transaction.aggregate([ + { $match: match }, + { + $group: { + _id: null, + totalVolume: { $sum: { $toDecimal: "$amount" } }, + transactionCount: { $sum: 1 }, + uniqueBuyers: { $addToSet: "$buyer" }, + }, + }, + { + $project: { + _id: 0, + totalVolume: { $toString: "$totalVolume" }, + transactionCount: 1, + uniqueBuyerCount: { $size: "$uniqueBuyers" }, + }, + }, + ]), + Transaction.aggregate([ + { $match: match }, + { $group: { _id: "$itemId", title: { $first: "$itemTitle" }, totalVolume: { $sum: { $toDecimal: "$amount" } }, sales: { $sum: 1 } } }, + { $sort: { totalVolume: -1, sales: -1 } }, + { $limit: parseInt(limit, 10) || 10 }, + { $project: { _id: 0, itemId: "$_id", title: 1, totalVolume: { $toString: "$totalVolume" }, sales: 1 } }, + ]), + ]); + + const summaryData = summary[0] || { totalVolume: "0", transactionCount: 0, uniqueBuyerCount: 0 }; + + res.status(200).json({ + success: true, + summary: { + totalVolume: toDecimalString(summaryData.totalVolume), + transactionCount: summaryData.transactionCount || 0, + uniqueBuyers: summaryData.uniqueBuyerCount || 0, + }, + items, + }); + } catch (error) { + logger.error("Analytics educator earnings error:", error); + res.status(500).json({ success: false, message: "Failed to fetch educator earnings" }); + } +}; + +export const getPlatformAnalytics = async (req, res) => { + try { + const { from, to, interval = "day", currency, network, limit = 10 } = req.query; + if (req.user.role !== "admin") { + return res.status(403).json({ success: false, message: "Only admins can view platform analytics" }); + } + + const match = { + status: "confirmed", + ...buildDateFilter({ from, to }), + }; + if (currency) match.currency = currency; + if (network) match.network = network; + + const [summary, timeseries, topEducators, topItems] = await Promise.all([ + Transaction.aggregate([ + { $match: match }, + { + $group: { + _id: null, + totalVolume: { $sum: { $toDecimal: "$amount" } }, + transactionCount: { $sum: 1 }, + uniqueBuyers: { $addToSet: "$buyer" }, + currencies: { $addToSet: "$currency" }, + networks: { $addToSet: "$network" }, + }, + }, + { + $project: { + _id: 0, + totalVolume: { $toString: "$totalVolume" }, + transactionCount: 1, + uniqueBuyerCount: { $size: "$uniqueBuyers" }, + currencies: 1, + networks: 1, + }, + }, + ]), + Transaction.aggregate([ + { $match: match }, + { + $group: { + _id: { + day: { $dateTrunc: { date: "$confirmedAt", unit: interval === "month" ? "month" : interval === "week" ? "week" : "day" } }, + }, + volume: { $sum: { $toDecimal: "$amount" } }, + count: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + { $project: { _id: 0, bucket: "$_id.day", volume: { $toString: "$volume" }, count: 1 } }, + ]), + Transaction.aggregate([ + { $match: match }, + { $group: { _id: "$creator", totalVolume: { $sum: { $toDecimal: "$amount" } }, sales: { $sum: 1 } } }, + { $sort: { totalVolume: -1, sales: -1 } }, + { $limit: parseInt(limit, 10) || 10 }, + { $lookup: { from: "users", localField: "_id", foreignField: "_id", as: "creatorProfile" } }, + { $unwind: { path: "$creatorProfile", preserveNullAndEmptyArrays: true } }, + { $project: { _id: 0, creatorId: "$_id", name: "$creatorProfile.name", avatar: "$creatorProfile.avatar", totalVolume: { $toString: "$totalVolume" }, sales: 1 } }, + ]), + Transaction.aggregate([ + { $match: match }, + { $group: { _id: "$itemId", title: { $first: "$itemTitle" }, totalVolume: { $sum: { $toDecimal: "$amount" } }, sales: { $sum: 1 } } }, + { $sort: { totalVolume: -1, sales: -1 } }, + { $limit: parseInt(limit, 10) || 10 }, + { $project: { _id: 0, itemId: "$_id", title: 1, totalVolume: { $toString: "$totalVolume" }, sales: 1 } }, + ]), + ]); + + const summaryData = summary[0] || { totalVolume: "0", transactionCount: 0, uniqueBuyerCount: 0, currencies: [], networks: [] }; + + res.status(200).json({ + success: true, + summary: { + totalVolume: toDecimalString(summaryData.totalVolume), + transactionCount: summaryData.transactionCount || 0, + uniqueBuyers: summaryData.uniqueBuyerCount || 0, + currencies: summaryData.currencies || [], + networks: summaryData.networks || [], + }, + timeseries, + topEducators: topEducators || [], + topItems: topItems || [], + }); + } catch (error) { + logger.error("Platform analytics error:", error); + res.status(500).json({ success: false, message: "Failed to fetch platform analytics" }); + } +}; + +export const getCourseProgress = async (req, res) => { + try { + const courseId = req.params.id; + const userId = req.user._id; + const course = await Course.findById(courseId); + if (!course) return res.status(404).json({ success: false, message: "Course not found" }); + + const isOwner = course.createdBy && course.createdBy.toString() === userId.toString(); + const isEnrolled = course.enrolledUsers?.some((entry) => entry.toString() === userId.toString()); + if (!isOwner && !isEnrolled) { + return res.status(403).json({ success: false, message: "You do not have access to this course" }); + } + + let progress = await CourseProgress.findOne({ user: userId, course: courseId }); + if (!progress) { + const lessonCount = (course.sections || []).reduce((count, section) => count + (section.lessons || []).length, 0) || 1; + progress = await CourseProgress.create({ + user: userId, + course: courseId, + lessonsCompleted: [], + lastLesson: null, + lastPositionSeconds: 0, + percentComplete: 0, + }); + progress = progress.toObject(); + } + + res.status(200).json({ success: true, progress }); + } catch (error) { + logger.error("Get course progress error:", error); + res.status(500).json({ success: false, message: "Failed to fetch course progress" }); + } +}; + +export const updateCourseProgress = async (req, res) => { + try { + const courseId = req.params.id; + const userId = req.user._id; + const { lessonId, lastPositionSeconds } = req.body; + const course = await Course.findById(courseId); + if (!course) return res.status(404).json({ success: false, message: "Course not found" }); + + const isOwner = course.createdBy && course.createdBy.toString() === userId.toString(); + const isEnrolled = course.enrolledUsers?.some((entry) => entry.toString() === userId.toString()); + if (!isOwner && !isEnrolled) { + return res.status(403).json({ success: false, message: "You do not have access to this course" }); + } + + const lessonCount = (course.sections || []).reduce((count, section) => count + (section.lessons || []).length, 0) || 1; + + let progress = await CourseProgress.findOne({ user: userId, course: courseId }); + if (!progress) { + progress = await CourseProgress.create({ + user: userId, + course: courseId, + lessonsCompleted: [], + lastLesson: null, + lastPositionSeconds: 0, + percentComplete: 0, + }); + } + + if (lessonId) { + const lessonObjectId = new mongoose.Types.ObjectId(lessonId); + progress.lessonsCompleted = Array.from(new Set([...(progress.lessonsCompleted || []), lessonObjectId])); + } + + progress.lastLesson = lessonId ? new mongoose.Types.ObjectId(lessonId) : progress.lastLesson; + progress.lastPositionSeconds = lastPositionSeconds ?? progress.lastPositionSeconds; + const percent = Math.min(100, Math.round((progress.lessonsCompleted.length / lessonCount) * 100)); + progress.percentComplete = percent; + if (percent >= 100) progress.completedAt = progress.completedAt || new Date(); + await progress.save(); + + res.status(200).json({ success: true, progress }); + } catch (error) { + logger.error("Update course progress error:", error); + res.status(500).json({ success: false, message: "Failed to update course progress" }); + } +}; + +export const getUserLearning = async (req, res) => { + try { + const userId = req.user._id; + const { status = "in-progress" } = req.query; + const progressRecords = await CourseProgress.find({ user: userId }).sort({ updatedAt: -1 }).populate("course"); + const courses = progressRecords + .map((record) => ({ + _id: record.course?._id, + title: record.course?.title || "Course", + percentComplete: record.percentComplete || 0, + lastLesson: record.lastLesson, + lastPositionSeconds: record.lastPositionSeconds || 0, + completedAt: record.completedAt, + updatedAt: record.updatedAt, + })) + .filter((entry) => { + if (status === "completed") return entry.percentComplete >= 100; + return entry.percentComplete < 100; + }); + + res.status(200).json({ success: true, courses }); + } catch (error) { + logger.error("Get user learning error:", error); + res.status(500).json({ success: false, message: "Failed to fetch learning dashboard" }); + } +}; diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js index 3399eb43..f93b8557 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -11,7 +11,7 @@ import { createNewCourseNotification } from "../notificationController.js"; * Backend receives URLs instead of file buffers */ export const createCourse = catchAsync(async (req, res, next) => { - const { title, description, category, price, thumbnail, video } = req.body; + const { title, description, category, price, thumbnail, video, sections } = req.body; logger.info(`Creating course: ${title} by user: ${req.user._id}`); @@ -31,6 +31,7 @@ export const createCourse = catchAsync(async (req, res, next) => { createdBy: req.user._id, thumbnail: thumbnail || null, // URL from frontend video: video || null, // URL from frontend + ...(Array.isArray(sections) && sections.length > 0 ? { sections } : {}), }); logger.info(`✅ Course created successfully: ${course._id} - ${title}`); @@ -164,7 +165,7 @@ export const enrollInCourse = async (req, res) => { // 📝 Edit/Update a course export const updateCourse = catchAsync(async (req, res, next) => { - const { title, description, category, price, thumbnail, video } = req.body; + const { title, description, category, price, thumbnail, video, sections } = req.body; const courseId = req.params.id; logger.info(`Updating course: ${courseId}`); @@ -191,6 +192,7 @@ export const updateCourse = catchAsync(async (req, res, next) => { // Update media URLs if provided if (thumbnail) course.thumbnail = thumbnail; if (video) course.video = video; + if (Array.isArray(sections)) course.sections = sections; await course.save(); diff --git a/src/controllers/userController.js b/src/controllers/userController.js index 3d9b0439..6c83eabb 100644 --- a/src/controllers/userController.js +++ b/src/controllers/userController.js @@ -4,6 +4,7 @@ import Course from "../models/Course.js"; import Book from "../models/Book.js"; import logger from "../config/logger.js"; import { validateMagicBytes } from "../utils/fileValidation.js"; +import CourseProgress from "../models/CourseProgress.js"; import { createFollowNotification, createUnfollowNotification } from "./notificationController.js"; // Update user profile (including avatar upload to Cloudinary) @@ -530,6 +531,39 @@ export const getRecommendations = async (req, res) => { } }; +export const getLearningDashboard = async (req, res) => { + try { + const userId = req.user._id; + const { status = "in-progress" } = req.query; + + const progressRecords = await CourseProgress.find({ user: userId }) + .sort({ updatedAt: -1 }) + .populate("course", "title thumbnail createdBy") + .lean(); + + const courses = progressRecords + .filter((record) => { + if (status === "completed") return record.percentComplete >= 100; + return record.percentComplete < 100; + }) + .map((record) => ({ + _id: record.course?._id, + title: record.course?.title || "Course", + thumbnail: record.course?.thumbnail || null, + percentComplete: record.percentComplete || 0, + lastLesson: record.lastLesson, + lastPositionSeconds: record.lastPositionSeconds || 0, + completedAt: record.completedAt, + updatedAt: record.updatedAt, + })); + + res.status(200).json({ success: true, courses }); + } catch (error) { + logger.error("Get learning dashboard error:", error); + res.status(500).json({ success: false, message: "Failed to fetch learning dashboard" }); + } +}; + // Get user statistics export const getUserStats = async (req, res) => { try { diff --git a/src/models/Course.js b/src/models/Course.js index db43f1cf..fa8497e9 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -25,6 +25,23 @@ const courseSchema = new mongoose.Schema( type: Number, default: 0, }, + sections: [ + { + title: { type: String, trim: true }, + order: { type: Number, default: 0 }, + lessons: [ + { + _id: { type: mongoose.Schema.Types.ObjectId, auto: true }, + title: { type: String, trim: true, required: true }, + order: { type: Number, default: 0 }, + videoUrl: { type: String }, + durationSeconds: { type: Number, default: 0 }, + isPreview: { type: Boolean, default: false }, + resources: [{ type: String }], + }, + ], + }, + ], reviews: [ { user: { diff --git a/src/models/CourseProgress.js b/src/models/CourseProgress.js new file mode 100644 index 00000000..0a6cdee8 --- /dev/null +++ b/src/models/CourseProgress.js @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +const courseProgressSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + course: { + type: mongoose.Schema.Types.ObjectId, + ref: "Course", + required: true, + index: true, + }, + lessonsCompleted: [{ type: mongoose.Schema.Types.ObjectId }], + lastLesson: { + type: mongoose.Schema.Types.ObjectId, + default: null, + }, + lastPositionSeconds: { + type: Number, + default: 0, + }, + percentComplete: { + type: Number, + default: 0, + }, + completedAt: { + type: Date, + default: null, + }, + }, + { timestamps: true } +); + +courseProgressSchema.index({ user: 1, course: 1 }, { unique: true }); + +export default mongoose.model("CourseProgress", courseProgressSchema); diff --git a/src/routes/analytics/analyticsRoutes.js b/src/routes/analytics/analyticsRoutes.js new file mode 100644 index 00000000..eef4ccc8 --- /dev/null +++ b/src/routes/analytics/analyticsRoutes.js @@ -0,0 +1,22 @@ +import express from "express"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { + getEducatorEarnings, + getPlatformAnalytics, + getCourseProgress, + updateCourseProgress, + getUserLearning, +} from "../../controllers/analytics/analyticsController.js"; + +const router = express.Router(); + +router.get("/me/earnings", protect, getEducatorEarnings); +router.get("/platform", protect, authorizeRoles("admin"), getPlatformAnalytics); +router.get("/platform/timeseries", protect, authorizeRoles("admin"), getPlatformAnalytics); +router.get("/top-educators", protect, authorizeRoles("admin"), getPlatformAnalytics); +router.get("/top-items", protect, authorizeRoles("admin"), getPlatformAnalytics); +router.get("/courses/:id/progress", protect, getCourseProgress); +router.post("/courses/:id/progress", protect, updateCourseProgress); +router.get("/users/me/learning", protect, getUserLearning); + +export default router; diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js index be3bfb88..0811300d 100644 --- a/src/routes/courses/courseRoutes.js +++ b/src/routes/courses/courseRoutes.js @@ -9,6 +9,10 @@ import { addCourseReview, fetchRecommendedCourses, } from "../../controllers/courses/courseController.js"; +import { + getCourseProgress, + updateCourseProgress, +} from "../../controllers/analytics/analyticsController.js"; import { toggleCourseBookmark, getBookmarkedCourses, @@ -49,6 +53,17 @@ router.post("/:courseId/bookmark", protect, toggleCourseBookmark); router.get("/:courseId/bookmark/check", protect, checkIfBookmarked); router.delete("/:courseId/bookmark", protect, removeBookmark); +router.get( + "/:id/progress", + protect, + getCourseProgress +); +router.post( + "/:id/progress", + protect, + updateCourseProgress +); + // Dynamic routes - cached for 15 minutes router.get( "/:id", diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 2d38387f..20a630bf 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -14,6 +14,7 @@ import { checkIfFollowing, getRecommendations, getUserStats, + getLearningDashboard, } from "../controllers/userController.js"; import { searchAll } from "../controllers/searchController.js"; import { @@ -125,5 +126,6 @@ router.get( cacheMiddleware(CACHE_TTL.USERS, userStatsCacheKey), getUserStats ); +router.get("/me/learning", protect, getLearningDashboard); export default router; diff --git a/test/analytics.test.js b/test/analytics.test.js new file mode 100644 index 00000000..afea57ac --- /dev/null +++ b/test/analytics.test.js @@ -0,0 +1,166 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +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 Transaction from "../src/models/Transaction.js"; + +describe("Revenue analytics endpoints", () => { + let mongoServer; + let adminToken; + let educatorToken; + let studentToken; + let educator; + let admin; + let student; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + educator = await User.create({ + name: "Educator", + email: "educator@example.com", + password: "password123", + role: "tutor", + }); + admin = await User.create({ + name: "Admin", + email: "admin@example.com", + password: "password123", + role: "admin", + }); + student = await User.create({ + name: "Student", + email: "student@example.com", + password: "password123", + role: "student", + }); + + educatorToken = jwt.sign({ userId: educator._id, sessionId: "s1" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); + adminToken = jwt.sign({ userId: admin._id, sessionId: "s2" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); + studentToken = jwt.sign({ userId: student._id, sessionId: "s3" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); + }); + + beforeEach(async () => { + await Promise.all([ + User.deleteMany({}), + Course.deleteMany({}), + Transaction.deleteMany({}), + ]); + + educator = await User.create({ + name: "Educator", + email: "educator@example.com", + password: "password123", + role: "tutor", + }); + admin = await User.create({ + name: "Admin", + email: "admin@example.com", + password: "password123", + role: "admin", + }); + student = await User.create({ + name: "Student", + email: "student@example.com", + password: "password123", + role: "student", + }); + + educatorToken = jwt.sign({ userId: educator._id, sessionId: "s1" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); + adminToken = jwt.sign({ userId: admin._id, sessionId: "s2" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); + studentToken = jwt.sign({ userId: student._id, sessionId: "s3" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + it("returns educator earnings data with precise decimal strings", async () => { + await Course.create({ + title: "Course 1", + description: "A test course", + category: "Tech", + createdBy: educator._id, + price: 5, + }); + + await Transaction.create([ + { + stellarTxHash: "hash-1", + buyer: student._id, + buyerWallet: "G123", + creator: educator._id, + creatorWallet: "G456", + itemType: "course", + itemId: new mongoose.Types.ObjectId(), + itemTypeModel: "Course", + itemTitle: "Course 1", + amount: "10.0000001", + network: "testnet", + status: "confirmed", + confirmedAt: new Date("2024-06-01T00:00:00.000Z"), + }, + { + stellarTxHash: "hash-2", + buyer: student._id, + buyerWallet: "G123", + creator: educator._id, + creatorWallet: "G456", + itemType: "course", + itemId: new mongoose.Types.ObjectId(), + itemTypeModel: "Course", + itemTitle: "Course 2", + amount: "2.0000001", + network: "testnet", + status: "confirmed", + confirmedAt: new Date("2024-06-02T00:00:00.000Z"), + }, + { + stellarTxHash: "hash-3", + buyer: student._id, + buyerWallet: "G123", + creator: educator._id, + creatorWallet: "G456", + itemType: "course", + itemId: new mongoose.Types.ObjectId(), + itemTypeModel: "Course", + itemTitle: "Course 3", + amount: "1.0000001", + network: "testnet", + status: "pending", + confirmedAt: new Date("2024-06-03T00:00:00.000Z"), + }, + ]); + + const res = await request(app) + .get("/api/analytics/me/earnings") + .set("Authorization", `Bearer ${educatorToken}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.summary.totalVolume).toBe("12.0000002"); + expect(res.body.summary.transactionCount).toBe(2); + expect(res.body.items[0].title).toBe("Course 1"); + }); + + it("blocks non-admin access to platform analytics and allows admins", async () => { + const studentRes = await request(app) + .get("/api/analytics/platform") + .set("Authorization", `Bearer ${studentToken}`); + + expect(studentRes.status).toBe(403); + + const adminRes = await request(app) + .get("/api/analytics/platform") + .set("Authorization", `Bearer ${adminToken}`); + + expect(adminRes.status).toBe(200); + expect(adminRes.body.success).toBe(true); + expect(adminRes.body.summary.transactionCount).toBe(0); + }); +});