diff --git a/app.js b/app.js index 1ae4e784..c5f5accb 100644 --- a/app.js +++ b/app.js @@ -55,6 +55,9 @@ import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes. import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js"; import webhookRoutes from "./src/routes/webhookRoutes.js"; import categoryRoutes from "./src/routes/categoryRoutes.js"; + +// Issue #245 — Search analytics +import searchAnalyticsRoutes from "./src/routes/analytics/search.js"; import { healthCheck, ping } from "./src/controllers/healthController.js"; const app = express(); @@ -225,6 +228,9 @@ app.use("/api/notifications", generousLimiter, notificationRoutes); // Outbound webhook management API (admin-gated) app.use("/api/webhooks", standardLimiter, webhookRoutes); +// Issue #245 — Search analytics (admin-gated; see routes/analytics/search.js) +app.use("/api/analytics/search", generousLimiter, searchAnalyticsRoutes); + // Internal service-to-service (dnb-ai) — signed-request auth, no user JWTs app.use("/api/internal/ai", internalAiRoutes); diff --git a/src/controllers/analytics/searchAnalyticsController.js b/src/controllers/analytics/searchAnalyticsController.js new file mode 100644 index 00000000..cba8099b --- /dev/null +++ b/src/controllers/analytics/searchAnalyticsController.js @@ -0,0 +1,120 @@ +import { + getTopSearchQueries, + getZeroResultSearches, + getSearchSummary, + getSearchTrends, +} from "../../services/analytics/search-analytics-service.js"; +import logger from "../../config/logger.js"; + +/** + * GET /api/analytics/search/top + * Get top search queries by frequency. + */ +export const getTopSearchQueriesHandler = async (req, res) => { + try { + const { startDate, endDate, type, limit, page } = req.query; + + const result = await getTopSearchQueries({ + startDate, + endDate, + type, + limit, + page, + }); + + res.status(200).json({ + success: true, + data: result.queries, + pagination: result.pagination, + }); + } catch (error) { + logger.error("Error fetching top search queries:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch top search queries", + }); + } +}; + +/** + * GET /api/analytics/search/zero-results + * Get zero-result searches. + */ +export const getZeroResultSearchesHandler = async (req, res) => { + try { + const { startDate, endDate, type, limit, page } = req.query; + + const result = await getZeroResultSearches({ + startDate, + endDate, + type, + limit, + page, + }); + + res.status(200).json({ + success: true, + data: result.queries, + pagination: result.pagination, + }); + } catch (error) { + logger.error("Error fetching zero-result searches:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch zero-result searches", + }); + } +}; + +/** + * GET /api/analytics/search/summary + * Get search analytics summary. + */ +export const getSearchSummaryHandler = async (req, res) => { + try { + const { startDate, endDate } = req.query; + + const summary = await getSearchSummary({ startDate, endDate }); + + res.status(200).json({ + success: true, + data: summary, + }); + } catch (error) { + logger.error("Error fetching search summary:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch search summary", + }); + } +}; + +/** + * GET /api/analytics/search/trends + * Get search trends over time. + */ +export const getSearchTrendsHandler = async (req, res) => { + try { + const { startDate, endDate, type } = req.query; + + const trends = await getSearchTrends({ startDate, endDate, type }); + + res.status(200).json({ + success: true, + data: trends, + }); + } catch (error) { + logger.error("Error fetching search trends:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch search trends", + }); + } +}; + +export default { + getTopSearchQueriesHandler, + getZeroResultSearchesHandler, + getSearchSummaryHandler, + getSearchTrendsHandler, +}; diff --git a/src/middlewares/analytics/search-logger.js b/src/middlewares/analytics/search-logger.js new file mode 100644 index 00000000..460dbb41 --- /dev/null +++ b/src/middlewares/analytics/search-logger.js @@ -0,0 +1,78 @@ +import { logSearchEvent } from "../../services/analytics/search-analytics-service.js"; +import logger from "../../config/logger.js"; + +/** + * Sum the number of returned items across the possible search response + * shapes. The search endpoints currently expose the payload either as + * `body.data` (standardized envelope) or the legacy `body.results`, and the + * payload itself may be a bare array (educators) or an object keyed by + * collection (courses/books/spaces/reels). + */ +const countResults = (body) => { + let payload = body?.data; + if (payload === undefined && body != null) { + payload = body.results; + } + if (payload === undefined) return 0; + + if (Array.isArray(payload)) return payload.length; + + if (payload && typeof payload === "object") { + let total = 0; + for (const items of Object.values(payload)) { + if (Array.isArray(items)) total += items.length; + } + return total; + } + + return 0; +}; + +/** + * Middleware to log search queries for analytics. + * + * Wires into the real search routes and records one event per query with a + * timestamp (via the model's `createdAt`), the result count, and whether the + * search returned zero results — the signal used to spot dead ends in + * content discovery. Logging is fire-and-forget: a failed write is logged + * and never fails or slows the search response. + */ +export const searchLogger = (req, res, next) => { + const originalJson = res.json.bind(res); + + res.json = function (body) { + // Restore the original so we don't double-wrap on subsequent calls. + res.json = originalJson; + + const query = (req.query.q || req.query.query || "").trim(); + if (query) { + const resultCount = countResults(body); + const event = { + userId: req.user?._id || null, + sessionId: req.headers["x-session-id"] || null, + query, + type: req.query.type || "all", + resultCount, + hasResults: resultCount > 0, + filters: { + category: req.query.category || null, + minPrice: req.query.minPrice || null, + maxPrice: req.query.maxPrice || null, + free: req.query.free || null, + minRating: req.query.minRating || null, + }, + userAgent: req.headers["user-agent"] || null, + }; + + logSearchEvent(event).catch((err) => + logger.error("Failed to log search event:", err) + ); + } + + return originalJson(body); + }; + + next(); +}; + +export default searchLogger; \ No newline at end of file diff --git a/src/models/search-analytics-event.js b/src/models/search-analytics-event.js new file mode 100644 index 00000000..daa586da --- /dev/null +++ b/src/models/search-analytics-event.js @@ -0,0 +1,64 @@ +import mongoose from "mongoose"; + +const searchAnalyticsEventSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + index: true, + }, + sessionId: { + type: String, + default: null, + index: true, + }, + query: { + type: String, + required: true, + trim: true, + index: true, + }, + type: { + type: String, + default: "all", + enum: ["all", "courses", "books", "spaces", "reels", "educators"], + }, + resultCount: { + type: Number, + default: 0, + min: 0, + }, + hasResults: { + type: Boolean, + default: true, + index: true, + }, + filters: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, + userAgent: { + type: String, + default: null, + }, + }, + { + timestamps: true, + } +); + +// Lookup by query popularity within a window. +searchAnalyticsEventSchema.index({ query: 1, createdAt: -1 }); +// Efficient zero-result reporting. +searchAnalyticsEventSchema.index({ hasResults: 1, createdAt: -1 }); +// Rolling 90-day retention; older events are auto-pruned. +searchAnalyticsEventSchema.index( + { createdAt: 1 }, + { expireAfterSeconds: 90 * 24 * 60 * 60 } +); + +export default mongoose.model( + "SearchAnalyticsEvent", + searchAnalyticsEventSchema +); \ No newline at end of file diff --git a/src/routes/analytics/search.js b/src/routes/analytics/search.js new file mode 100644 index 00000000..e7468c45 --- /dev/null +++ b/src/routes/analytics/search.js @@ -0,0 +1,27 @@ +import express from "express"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { + getTopSearchQueriesHandler, + getZeroResultSearchesHandler, + getSearchSummaryHandler, + getSearchTrendsHandler, +} from "../../controllers/analytics/searchAnalyticsController.js"; + +const router = express.Router(); + +// All search analytics endpoints require admin authentication +router.use(protect, authorizeRoles("admin")); + +// Top search queries by frequency +router.get("/top", getTopSearchQueriesHandler); + +// Zero-result searches +router.get("/zero-results", getZeroResultSearchesHandler); + +// Search summary metrics +router.get("/summary", getSearchSummaryHandler); + +// Search trends over time +router.get("/trends", getSearchTrendsHandler); + +export default router; diff --git a/src/routes/searchRoutes.js b/src/routes/searchRoutes.js index 26905fad..7d8472ac 100644 --- a/src/routes/searchRoutes.js +++ b/src/routes/searchRoutes.js @@ -1,7 +1,7 @@ import express from "express"; -import { searchAll } from "../controllers/searchController.js"; import { searchAll, searchEducatorsHandler } from "../controllers/searchController.js"; import { cacheMiddleware } from "../middlewares/cache.js"; +import { searchLogger } from "../middlewares/analytics/search-logger.js"; import { CACHE_TTL, CACHE_KEYS } from "../utils/cache.js"; const router = express.Router(); @@ -10,11 +10,6 @@ const router = express.Router(); const searchCacheKey = (req) => { const query = req.query.q || req.query.query || ""; const type = req.query.type || "all"; - return `${CACHE_KEYS.SEARCH}${type}:${query.toLowerCase().trim()}`; -}; - -// Main search endpoint - cached for 5 minutes -router.get("/", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll); const page = req.query.page || 1; const limit = req.query.limit || 10; const filterKeys = ['minPrice', 'maxPrice', 'free', 'category', 'minRating', 'interest', 'sort']; @@ -22,10 +17,10 @@ router.get("/", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll); return `${CACHE_KEYS.SEARCH}${req.path}:${type}:${query.toLowerCase().trim()}:page=${page}:limit=${limit}:${filtersStr}`; }; -// Main search endpoint -router.get("/", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll); +// Main search endpoint — logs every query for analytics (issue #245). +router.get("/", searchLogger, cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll); -// Dedicated educators endpoint -router.get("/educators", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchEducatorsHandler); +// Dedicated educators endpoint — logs every query for analytics (issue #245). +router.get("/educators", searchLogger, cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchEducatorsHandler); export default router; diff --git a/src/services/analytics/search-analytics-service.js b/src/services/analytics/search-analytics-service.js new file mode 100644 index 00000000..553c84e0 --- /dev/null +++ b/src/services/analytics/search-analytics-service.js @@ -0,0 +1,268 @@ +import mongoose from "mongoose"; +import SearchAnalyticsEvent from "../../models/search-analytics-event.js"; +import logger from "../../config/logger.js"; + +/** + * Log a search event. + */ +export const logSearchEvent = async (eventData) => { + try { + const event = await SearchAnalyticsEvent.create(eventData); + return event; + } catch (error) { + logger.error("Failed to log search event:", error); + return null; + } +}; + +/** + * Get top search queries by frequency. + */ +export const getTopSearchQueries = async (filters = {}) => { + const { startDate, endDate, type, limit = 20, page = 1 } = filters; + + const matchStage = {}; + if (startDate || endDate) { + matchStage.createdAt = {}; + if (startDate) matchStage.createdAt.$gte = new Date(startDate); + if (endDate) matchStage.createdAt.$lte = new Date(endDate); + } + if (type) matchStage.type = type; + + const validLimit = Math.min(100, Math.max(1, Number(limit) || 20)); + const validPage = Math.max(1, Number(page) || 1); + const skip = (validPage - 1) * validLimit; + + const pipeline = [ + ...(Object.keys(matchStage).length > 0 ? [{ $match: matchStage }] : []), + { + $group: { + _id: { $toLower: "$query" }, + count: { $sum: 1 }, + lastSearched: { $max: "$createdAt" }, + uniqueUsers: { $addToSet: "$userId" }, + }, + }, + { + $project: { + query: "$_id", + count: 1, + lastSearched: 1, + uniqueUsers: { $size: "$uniqueUsers" }, + }, + }, + { $sort: { count: -1 } }, + { $skip: skip }, + { $limit: validLimit }, + ]; + + const [results, countResult] = await Promise.all([ + SearchAnalyticsEvent.aggregate(pipeline), + SearchAnalyticsEvent.aggregate([ + ...(Object.keys(matchStage).length > 0 ? [{ $match: matchStage }] : []), + { + $group: { + _id: { $toLower: "$query" }, + }, + }, + { $count: "total" }, + ]), + ]); + + const total = countResult[0]?.total || 0; + + return { + queries: results, + pagination: { + total, + page: validPage, + limit: validLimit, + pages: Math.ceil(total / validLimit), + }, + }; +}; + +/** + * Get zero-result searches (queries that returned no results). + */ +export const getZeroResultSearches = async (filters = {}) => { + const { startDate, endDate, type, limit = 20, page = 1 } = filters; + + const matchStage = { hasResults: false }; + if (startDate || endDate) { + matchStage.createdAt = {}; + if (startDate) matchStage.createdAt.$gte = new Date(startDate); + if (endDate) matchStage.createdAt.$lte = new Date(endDate); + } + if (type) matchStage.type = type; + + const validLimit = Math.min(100, Math.max(1, Number(limit) || 20)); + const validPage = Math.max(1, Number(page) || 1); + const skip = (validPage - 1) * validLimit; + + const pipeline = [ + { $match: matchStage }, + { + $group: { + _id: { $toLower: "$query" }, + count: { $sum: 1 }, + lastSearched: { $max: "$createdAt" }, + }, + }, + { + $project: { + query: "$_id", + count: 1, + lastSearched: 1, + }, + }, + { $sort: { count: -1 } }, + { $skip: skip }, + { $limit: validLimit }, + ]; + + const [results, countResult] = await Promise.all([ + SearchAnalyticsEvent.aggregate(pipeline), + SearchAnalyticsEvent.aggregate([ + { $match: matchStage }, + { + $group: { + _id: { $toLower: "$query" }, + }, + }, + { $count: "total" }, + ]), + ]); + + const total = countResult[0]?.total || 0; + + return { + queries: results, + pagination: { + total, + page: validPage, + limit: validLimit, + pages: Math.ceil(total / validLimit), + }, + }; +}; + +/** + * Get search analytics summary. + */ +export const getSearchSummary = async (filters = {}) => { + const { startDate, endDate } = filters; + + const matchStage = {}; + if (startDate || endDate) { + matchStage.createdAt = {}; + if (startDate) matchStage.createdAt.$gte = new Date(startDate); + if (endDate) matchStage.createdAt.$lte = new Date(endDate); + } + + const pipeline = [ + ...(Object.keys(matchStage).length > 0 ? [{ $match: matchStage }] : []), + { + $group: { + _id: null, + totalSearches: { $sum: 1 }, + uniqueQueries: { $addToSet: { $toLower: "$query" } }, + uniqueUsers: { $addToSet: "$userId" }, + zeroResultSearches: { + $sum: { $cond: ["$hasResults", 0, 1] }, + }, + searchesWithResults: { + $sum: { $cond: ["$hasResults", 1, 0] }, + }, + }, + }, + { + $project: { + _id: 0, + totalSearches: 1, + uniqueQueries: { $size: "$uniqueQueries" }, + uniqueUsers: { $size: "$uniqueUsers" }, + zeroResultSearches: 1, + searchesWithResults: 1, + zeroResultRate: { + $cond: [ + { $eq: ["$totalSearches", 0] }, + 0, + { + $round: [ + { + $multiply: [ + { $divide: ["$zeroResultSearches", "$totalSearches"] }, + 100, + ], + }, + 2, + ], + }, + ], + }, + }, + }, + ]; + + const [result] = await SearchAnalyticsEvent.aggregate(pipeline); + return result || { + totalSearches: 0, + uniqueQueries: 0, + uniqueUsers: 0, + zeroResultSearches: 0, + searchesWithResults: 0, + zeroResultRate: 0, + }; +}; + +/** + * Get search trends (searches per day over time). + */ +export const getSearchTrends = async (filters = {}) => { + const { startDate, endDate, type } = filters; + + const matchStage = {}; + if (startDate || endDate) { + matchStage.createdAt = {}; + if (startDate) matchStage.createdAt.$gte = new Date(startDate); + if (endDate) matchStage.createdAt.$lte = new Date(endDate); + } + if (type) matchStage.type = type; + + const pipeline = [ + ...(Object.keys(matchStage).length > 0 ? [{ $match: matchStage }] : []), + { + $group: { + _id: { + date: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } }, + hasResults: "$hasResults", + }, + count: { $sum: 1 }, + }, + }, + { + $group: { + _id: "$_id.date", + total: { $sum: "$count" }, + withResults: { + $sum: { $cond: ["$_id.hasResults", "$count", 0] }, + }, + zeroResults: { + $sum: { $cond: ["$_id.hasResults", 0, "$count"] }, + }, + }, + }, + { $sort: { _id: 1 } }, + ]; + + return SearchAnalyticsEvent.aggregate(pipeline); +}; + +export default { + logSearchEvent, + getTopSearchQueries, + getZeroResultSearches, + getSearchSummary, + getSearchTrends, +}; diff --git a/test/searchAnalytics.test.js b/test/searchAnalytics.test.js new file mode 100644 index 00000000..9d84f423 --- /dev/null +++ b/test/searchAnalytics.test.js @@ -0,0 +1,252 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockCreate = jest.fn(); +const mockAggregate = jest.fn(); + +const SearchAnalyticsEvent = { + create: mockCreate, + aggregate: mockAggregate, +}; + +jest.unstable_mockModule("../src/models/search-analytics-event.js", () => ({ + default: SearchAnalyticsEvent, +})); + +jest.unstable_mockModule("../src/config/logger.js", () => ({ + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.unstable_mockModule("../src/middlewares/authMiddleware.js", () => ({ + protect: (req, _res, next) => { + req.user = { + _id: new mongoose.Types.ObjectId(), + role: "admin", + }; + next(); + }, + authorizeRoles: + (...roles) => + (req, _res, next) => { + if (!req.user || !roles.includes(req.user.role)) { + return _res + .status(403) + .json({ success: false, message: "Forbidden" }); + } + next(); + }, +})); + +// ── Import routes after mocks ───────────────────────────────────────────────── + +const searchAnalyticsRoutes = ( + await import("../src/routes/analytics/search.js") +).default; + +const mount = () => { + const app = express(); + app.use(express.json()); + app.use("/api/analytics/search", searchAnalyticsRoutes); + return app; +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("Issue #245 — Search Analytics", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GET /api/analytics/search/top (getTopSearchQueriesHandler)", () => { + it("returns 200 with top search queries", async () => { + mockAggregate + .mockResolvedValueOnce([ + { query: "react tutorial", count: 150, lastSearched: new Date(), uniqueUsers: 80 }, + { query: "javascript basics", count: 120, lastSearched: new Date(), uniqueUsers: 65 }, + ]) + .mockResolvedValueOnce([{ total: 2 }]); + + const res = await request(mount()).get("/api/analytics/search/top"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0].query).toBe("react tutorial"); + expect(res.body.data[0].count).toBe(150); + expect(res.body.pagination).toBeDefined(); + }); + + it("returns 200 with empty array when no searches exist", async () => { + mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([{ total: 0 }]); + + const res = await request(mount()).get("/api/analytics/search/top"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual([]); + }); + + it("supports date range filtering", async () => { + mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([{ total: 0 }]); + + const res = await request(mount()).get( + "/api/analytics/search/top?startDate=2026-01-01&endDate=2026-12-31" + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("supports type filtering", async () => { + mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([{ total: 0 }]); + + const res = await request(mount()).get("/api/analytics/search/top?type=courses"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + describe("GET /api/analytics/search/zero-results (getZeroResultSearchesHandler)", () => { + it("returns 200 with zero-result queries", async () => { + mockAggregate + .mockResolvedValueOnce([ + { query: "nonexistent topic", count: 25, lastSearched: new Date() }, + ]) + .mockResolvedValueOnce([{ total: 1 }]); + + const res = await request(mount()).get("/api/analytics/search/zero-results"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].query).toBe("nonexistent topic"); + expect(res.body.data[0].count).toBe(25); + }); + + it("returns 200 with empty array when no zero-result searches", async () => { + mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([{ total: 0 }]); + + const res = await request(mount()).get("/api/analytics/search/zero-results"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual([]); + }); + }); + + describe("GET /api/analytics/search/summary (getSearchSummaryHandler)", () => { + it("returns 200 with search summary metrics", async () => { + mockAggregate.mockResolvedValue([ + { + totalSearches: 1000, + uniqueQueries: 250, + uniqueUsers: 150, + zeroResultSearches: 50, + searchesWithResults: 950, + zeroResultRate: 5.0, + }, + ]); + + const res = await request(mount()).get("/api/analytics/search/summary"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.totalSearches).toBe(1000); + expect(res.body.data.uniqueQueries).toBe(250); + expect(res.body.data.zeroResultRate).toBe(5.0); + }); + + it("returns default summary when no events exist", async () => { + mockAggregate.mockResolvedValue([]); + + const res = await request(mount()).get("/api/analytics/search/summary"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.totalSearches).toBe(0); + expect(res.body.data.zeroResultRate).toBe(0); + }); + }); + + describe("GET /api/analytics/search/trends (getSearchTrendsHandler)", () => { + it("returns 200 with search trends over time", async () => { + mockAggregate.mockResolvedValue([ + { _id: "2026-08-01", total: 50, withResults: 45, zeroResults: 5 }, + { _id: "2026-08-02", total: 60, withResults: 55, zeroResults: 5 }, + ]); + + const res = await request(mount()).get("/api/analytics/search/trends"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0]._id).toBe("2026-08-01"); + expect(res.body.data[0].total).toBe(50); + }); + + it("returns empty trends when no events exist", async () => { + mockAggregate.mockResolvedValue([]); + + const res = await request(mount()).get("/api/analytics/search/trends"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual([]); + }); + }); + + describe("Authentication", () => { + it("routes are mounted and respond (auth is enforced by protect + authorizeRoles middleware)", async () => { + // The protect and authorizeRoles middlewares are mocked to always pass. + // In production, authorizeRoles("admin") blocks non-admin users. + // This test verifies the routes exist and respond. + mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([{ total: 0 }]); + const res = await request(mount()).get("/api/analytics/search/top"); + expect(res.status).not.toBe(404); + expect(res.body.success).toBe(true); + }); + }); + + describe("Response envelope consistency", () => { + it("all endpoints return success boolean and data key", async () => { + mockAggregate.mockResolvedValue([]); + mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([{ total: 0 }]); + + const app = mount(); + + const topRes = await request(app).get("/api/analytics/search/top"); + expect(typeof topRes.body.success).toBe("boolean"); + expect("data" in topRes.body).toBe(true); + + mockAggregate.mockResolvedValue([]); + mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([{ total: 0 }]); + + const zeroRes = await request(app).get("/api/analytics/search/zero-results"); + expect(typeof zeroRes.body.success).toBe("boolean"); + expect("data" in zeroRes.body).toBe(true); + + mockAggregate.mockResolvedValue([]); + const summaryRes = await request(app).get("/api/analytics/search/summary"); + expect(typeof summaryRes.body.success).toBe("boolean"); + expect("data" in summaryRes.body).toBe(true); + + mockAggregate.mockResolvedValue([]); + const trendsRes = await request(app).get("/api/analytics/search/trends"); + expect(typeof trendsRes.body.success).toBe("boolean"); + expect("data" in trendsRes.body).toBe(true); + }); + }); +}); diff --git a/test/searchAnalyticsIntegration.test.js b/test/searchAnalyticsIntegration.test.js new file mode 100644 index 00000000..204a9aff --- /dev/null +++ b/test/searchAnalyticsIntegration.test.js @@ -0,0 +1,154 @@ +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 SearchAnalyticsEvent from "../src/models/search-analytics-event.js"; + +let mongoServer; + +const waitForEvents = async (minCount, timeoutMs = 4000) => { + const deadline = Date.now() + timeoutMs; + let count = 0; + while (Date.now() < deadline) { + count = await SearchAnalyticsEvent.countDocuments(); + if (count >= minCount) return count; + await new Promise((r) => setTimeout(r, 50)); + } + return count; +}; + +beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_searchanalytics`, { + serverSelectionTimeoutMS: 2000, + }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}, 60000); + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + +let adminToken; +let studentToken; + +beforeEach(async () => { + await SearchAnalyticsEvent.deleteMany({}); + await Course.deleteMany({}); + await User.deleteMany({}); + const admin = await User.create({ + name: "Data Admin", + email: "analytics-admin@example.com", + password: "Qx7#vLmp92Zt", + role: "admin", + twoFactor: { enabled: true }, + }); + const student = await User.create({ + name: "Search Student", + email: "search-student@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + twoFactor: { enabled: false }, + }); + const secret = + process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + adminToken = jwt.sign( + { userId: admin._id, sessionId: "admin-session", is2FAVerified: true }, + secret + ); + studentToken = jwt.sign( + { userId: student._id, sessionId: "student-session" }, + secret + ); +}); + +describe("Issue #245 — search analytics end-to-end (real /api/search path)", () => { + it("logs successful searches with a timestamp and result count", async () => { + await Course.create({ + title: "Fiqh Fundamentals", + description: "Core jurisprudence", + category: "Fiqh", + price: 0, + createdBy: new mongoose.Types.ObjectId(), + }); + + const res = await request(app).get("/api/search?q=Fi"); + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + + // The searchLogger writes asynchronously; wait for it to land. + const saved = await waitForEvents(1); + expect(saved).toBeGreaterThanOrEqual(1); + + const event = await SearchAnalyticsEvent.findOne({ query: "Fi" }).lean(); + expect(event).toBeTruthy(); + expect(event.hasResults).toBe(true); + expect(event.resultCount).toBeGreaterThan(0); + expect(event.type).toBe("all"); + expect(event.createdAt).toBeInstanceOf(Date); // timestamp recorded + }); + + it("distinguishes and exposes zero-result searches", async () => { + const search = await request(app).get("/api/search?q=zz"); + expect(search.statusCode).toBe(200); + expect(search.body.success).toBe(true); + + const saved = await waitForEvents(1); + expect(saved).toBeGreaterThanOrEqual(1); + + const event = await SearchAnalyticsEvent.findOne({ query: "zz" }).lean(); + expect(event).toBeTruthy(); + expect(event.hasResults).toBe(false); + expect(event.resultCount).toBe(0); + + // The stored zero-result query is surfaced by the analytics endpoint. + const res = await request(app) + .get("/api/analytics/search/zero-results") + .set("Authorization", `Bearer ${adminToken}`); + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + const queries = res.body.data.map((q) => q.query); + expect(queries).toContain("zz"); + }); + + it("reports top queries by frequency via the admin endpoint", async () => { + await request(app).get("/api/search?q=zz"); + await request(app).get("/api/search?q=zz"); + await waitForEvents(2); + + const res = await request(app) + .get("/api/analytics/search/top") + .set("Authorization", `Bearer ${adminToken}`); + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + const top = res.body.data[0]; + expect(top.query).toBe("zz"); + expect(top.count).toBeGreaterThanOrEqual(2); + }); + + it("guards the analytics endpoints (401 anonymous, 403 non-admin)", async () => { + const anon = await request(app).get("/api/analytics/search/top"); + expect(anon.statusCode).toBe(401); + + const forbidden = await request(app) + .get("/api/analytics/search/top") + .set("Authorization", `Bearer ${studentToken}`); + expect(forbidden.statusCode).toBe(403); + }); +}); \ No newline at end of file