diff --git a/.env b/.env index faadfe5..8e6f5f8 100644 --- a/.env +++ b/.env @@ -44,4 +44,7 @@ VITE_RECAPATCH=6LfLqG0sAAAAAIVBgjd_WSzhhcbIA8Z_boHtbtUv -VITE_VAPID_PUBLIC_KEY=BJmu1K4k02ru__di6cZa40gJD1b8wRy_K2oivy6LIzAf-EMBYl3u2fjTH1yUKp2bp2YZIPqhE0A0GsaczxqAJ9A \ No newline at end of file +VITE_VAPID_PUBLIC_KEY=BJmu1K4k02ru__di6cZa40gJD1b8wRy_K2oivy6LIzAf-EMBYl3u2fjTH1yUKp2bp2YZIPqhE0A0GsaczxqAJ9A + + +VITE_CMS_ANALYTICS_ENDPOINT=/api/cms-analytics \ No newline at end of file diff --git a/src/domain/entity/CMSAnalytics.js b/src/domain/entity/CMSAnalytics.js new file mode 100644 index 0000000..1d6471b --- /dev/null +++ b/src/domain/entity/CMSAnalytics.js @@ -0,0 +1,37 @@ +/** + * Domain Entity for Analytics + * Represents the core business logic and structure. + */ +export class CMSAnalytics { + constructor({ users, courses, events, reports, contributors }) { + this.users = this._normalize(users); + this.courses = this._normalize(courses); + this.events = this._normalize(events); + this.reports = this._normalize(reports); + this.contributors = { + authors: this._normalize(contributors?.authors), + organizers: this._normalize(contributors?.organizers) + }; + } + + _normalize(data) { + return { + total: data?.total || 0, + timeline: (data?.timeline || []).map(t => ({ + time: t.time, + count: Number(t.count || 0) + })) + }; + } + + getSummaryStats() { + return { + totalCourses: this.courses.total, + totalEvents: this.events.total, + totalUsers: this.users.total, + pendingReports: this.reports.total, + courseAuthors: this.contributors.authors.total, + eventOrganizers: this.contributors.organizers.total + }; + } +} diff --git a/src/domain/interface/IAnalyticsAccess.js b/src/domain/interface/IAnalyticsAccess.js new file mode 100644 index 0000000..52b80b9 --- /dev/null +++ b/src/domain/interface/IAnalyticsAccess.js @@ -0,0 +1,13 @@ +/** + * Interface for Analytics Data Access + * Defines the contract for fetching CMS analytics. + */ +export class IAnalyticsAccess { + /** + * @param {CMSAnalyticsRequestDTO} requestDto + * @returns {Promise} raw analytics data + */ + async getFullAnalytics(requestDto) { + throw new Error("Method getFullAnalytics() must be implemented."); + } +} diff --git a/src/domain/mapper/AnalyticsMapper.js b/src/domain/mapper/AnalyticsMapper.js new file mode 100644 index 0000000..b28d187 --- /dev/null +++ b/src/domain/mapper/AnalyticsMapper.js @@ -0,0 +1,21 @@ +import { CMSAnalytics } from "../entity/CMSAnalytics"; +import { CMSAnalyticsDTO } from "../../infrastructure/DTO/CMSAnalyticsDTO"; + +/** + * Mapper for Analytics related data transformations + */ +export class AnalyticsMapper { + /** + * Maps Raw Data from API to Domain Entity + */ + static toEntity(rawData) { + return new CMSAnalytics(rawData); + } + + /** + * Maps Domain Entity to Response DTO for the UI + */ + static toResponseDTO(entity) { + return new CMSAnalyticsDTO(entity); + } +} diff --git a/src/domain/useCase/useFetchCMSAnalytics.js b/src/domain/useCase/useFetchCMSAnalytics.js index b22e58e..7e022b4 100644 --- a/src/domain/useCase/useFetchCMSAnalytics.js +++ b/src/domain/useCase/useFetchCMSAnalytics.js @@ -1,54 +1,50 @@ -import { useState, useCallback } from 'react'; -import { CourseRepository } from '@infrastructure/repository/CourseRepository'; -import { EventRepository } from '@infrastructure/repository/EventRepository'; -import { ReportRepository } from '@infrastructure/repository/ReportRepository'; -import { UserRepository } from '@infrastructure/repository/UserRepository'; +import { useState, useCallback, useMemo } from 'react'; +import { AnalyticsRepository } from '../../infrastructure/repository/AnalyticsRepository'; +import { AnalyticsMapper } from '../mapper/AnalyticsMapper'; +import { CMSAnalyticsRequest } from '../../infrastructure/DTO/Request/CMSAnalyticsRequest'; /** - * UseCase: Fetches high-level metrics for the CMS Dashboard. - * Utilizes Promise.all to fetch metadata without heavily loading payload arrays. + * UseCase: Fetch CMS Analytics + * Orchestrates the flow using DTOs, Entities, and Mappers. */ export const useFetchCMSAnalytics = () => { + const repository = useMemo(() => new AnalyticsRepository(), []); + + const [data, setData] = useState(null); // Will hold a ResponseDTO const [stats, setStats] = useState({ - totalCourses: 0, - totalEvents: 0, - pendingReports: 0, - totalUsers: 0 + totalCourses: 0, totalEvents: 0, totalUsers: 0, + pendingReports: 0, courseAuthors: 0, eventOrganizers: 0 }); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const fetchAnalytics = useCallback(async () => { + const fetchAnalytics = useCallback(async (days = 60) => { setIsLoading(true); - setError(null); try { - const courseRepo = new CourseRepository(); - const eventRepo = new EventRepository(); - const reportRepo = new ReportRepository(); - const userRepo = new UserRepository(); - - const [coursesRes, eventsRes, reportsRes, usersRes] = await Promise.all([ - courseRepo.getAll(null, 1, 1), // passing size 1 just to get meta.total - eventRepo.getAll(1, 1), - reportRepo.getAll(1, 1, '', 'PENDING'), - userRepo.getAllUsers() - ]); - - setStats({ - totalCourses: coursesRes?.meta?.pagination?.total || 0, - totalEvents: eventsRes?.meta?.pagination?.total || 0, - pendingReports: reportsRes?.meta?.pagination?.total || 0, - totalUsers: Array.isArray(usersRes) ? usersRes.length : 0 - }); + // 1. Create Request DTO + const requestDto = new CMSAnalyticsRequest({ days }); + + // 2. Fetch via Repo + const rawData = await repository.getFullAnalytics(requestDto); + + // 3. Map to Entity via Mapper + const entity = AnalyticsMapper.toEntity(rawData); + + // 4. Map to Response DTO via Mapper + const responseDto = AnalyticsMapper.toResponseDTO(entity); + + setData(responseDto); + setStats(entity.getSummaryStats()); + setError(null); } catch (err) { - console.error('[CMS Analytics] Fetch failed:', err); - setError(err.message || 'Failed to load analytics'); + setError(err.message || "Failed to fetch scholarly analytics"); } finally { setIsLoading(false); } - }, []); + }, [repository]); return { + data, stats, isLoading, error, diff --git a/src/infrastructure/DTO/CMSAnalyticsDTO.js b/src/infrastructure/DTO/CMSAnalyticsDTO.js new file mode 100644 index 0000000..fb91c33 --- /dev/null +++ b/src/infrastructure/DTO/CMSAnalyticsDTO.js @@ -0,0 +1,13 @@ +/** + * Response DTO for Analytics (Low -> High) + * Standards for data delivery from repository to presentation + */ +export class CMSAnalyticsDTO { + constructor(entity) { + this.courses = entity.courses; + this.events = entity.events; + this.users = entity.users; + this.reports = entity.reports; + this.contributors = entity.contributors; + } +} diff --git a/src/infrastructure/DTO/Request/CMSAnalyticsRequest.js b/src/infrastructure/DTO/Request/CMSAnalyticsRequest.js new file mode 100644 index 0000000..152783e --- /dev/null +++ b/src/infrastructure/DTO/Request/CMSAnalyticsRequest.js @@ -0,0 +1,15 @@ +import { BaseRequest } from './BaseRequest'; + +/** + * Request DTO for Analytics (High -> Low) + */ +export class CMSAnalyticsRequest extends BaseRequest { + constructor(data = {}) { + super(data); + this.days = Number(data.days) || 60; + } + + toQueryString() { + return `?days=${this.days}`; + } +} diff --git a/src/infrastructure/repository/AnalyticsRepository.js b/src/infrastructure/repository/AnalyticsRepository.js new file mode 100644 index 0000000..c16e621 --- /dev/null +++ b/src/infrastructure/repository/AnalyticsRepository.js @@ -0,0 +1,29 @@ +import { IAnalyticsAccess } from '../../domain/interface/IAnalyticsAccess'; +import { repositoryRegistry } from './RepositoryRegistry'; + +/** + * AnalyticsRepository implementing IAnalyticsAccess. + * Responsible for fetching aggregated system metrics. + */ +export class AnalyticsRepository extends IAnalyticsAccess { + constructor(apiClient = repositoryRegistry.apiClient) { + super(); + this.apiClient = apiClient; + this.endpoint = import.meta.env.VITE_CMS_ANALYTICS_ENDPOINT || "/api/cms-analytics"; + } + + /** + * Concrete implementation of the IAnalyticsAccess contract + * @param {CMSAnalyticsRequestDTO} requestDto + */ + async getFullAnalytics(requestDto) { + try { + const query = requestDto.toQueryString(); + const response = await this.apiClient.get(`${this.endpoint}${query}`); + return response?.data || {}; + } catch (error) { + console.error('[AnalyticsRepository] Fetch error:', error); + throw error; + } + } +} diff --git a/src/presentation/feature/cms/components/dashboard/viewers/ContributorStatsViewer.jsx b/src/presentation/feature/cms/components/dashboard/viewers/ContributorStatsViewer.jsx new file mode 100644 index 0000000..0cb28d1 --- /dev/null +++ b/src/presentation/feature/cms/components/dashboard/viewers/ContributorStatsViewer.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { BookOpen, Calendar, Award } from 'lucide-react'; + +const ContributorStatsViewer = ({ stats = {} }) => { + return ( +
+
+
+

+ + Scholarly Contributors +

+

Verified authors and organizers across the academy

+
+
+ +
+ {/* Course Contributors */} +
+
+
+ +
+

Curriculum Authors

+
+ {stats.courseAuthors || 0} + Unique Educators +
+
+
+ Status + Active Pipeline +
+
+
+
+ + {/* Event Organizers */} +
+
+
+ +
+

Assembly Organizers

+
+ {stats.eventOrganizers || 0} + Event Leads +
+
+
+ Status + Live Network +
+
+
+
+
+
+ ); +}; + +export default ContributorStatsViewer; diff --git a/src/presentation/feature/cms/components/dashboard/viewers/DefaultDashboardViewer.jsx b/src/presentation/feature/cms/components/dashboard/viewers/DefaultDashboardViewer.jsx new file mode 100644 index 0000000..c32bb54 --- /dev/null +++ b/src/presentation/feature/cms/components/dashboard/viewers/DefaultDashboardViewer.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const DefaultDashboardViewer = () => { + // Current "Activity Trend" bar chart logic + const heights = [40, 60, 30, 80, 50, 70, 90, 45, 65, 85, 55, 75, 40, 60, 100]; + + return ( +
+
+

Platform Activity Trend

+ System Aggregate +
+ +
+ {heights.map((height, i) => ( +
+
+ {height} Load +
+
+ ))} +
+

Note: This represents cumulative system interactions across all published modules in the current session.

+
+ ); +}; + +export default DefaultDashboardViewer; diff --git a/src/presentation/feature/cms/components/dashboard/viewers/GrowthTimelineViewer.jsx b/src/presentation/feature/cms/components/dashboard/viewers/GrowthTimelineViewer.jsx new file mode 100644 index 0000000..4a3d98f --- /dev/null +++ b/src/presentation/feature/cms/components/dashboard/viewers/GrowthTimelineViewer.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { TrendingUp, Users } from 'lucide-react'; + +const COLOR_MAPS = { + "accent-primary": { bar: "bg-accent-primary/40", hover: "hover:bg-accent-primary", text: "text-accent-primary", border: "border-accent-primary/20", bg: "bg-accent-primary/5" }, + "accent-blue": { bar: "bg-accent-blue/40", hover: "hover:bg-accent-blue", text: "text-accent-blue", border: "border-accent-blue/20", bg: "bg-accent-blue/5" }, + "accent-violet": { bar: "bg-accent-violet/40", hover: "hover:bg-accent-violet", text: "text-accent-violet", border: "border-accent-violet/20", bg: "bg-accent-violet/5" }, + "accent-rose": { bar: "bg-accent-rose/40", hover: "hover:bg-accent-rose", text: "text-accent-rose", border: "border-accent-rose/20", bg: "bg-accent-rose/5" }, +}; + +const UnifiedTimelineViewer = ({ data = {}, title, subtitle, colorClass = "accent-blue" }) => { + const timeline = data.timeline || []; + const maxVal = Math.max(...timeline.map(d => d.count), 1); + const colors = COLOR_MAPS[colorClass] || COLOR_MAPS["accent-blue"]; + + return ( +
+
+
+

+ + {title} +

+

{subtitle}

+
+
+ Dynamic_Pulse +
+
+ +
+
+ {[0, 1, 2, 4].map(i =>
)} +
+ + {timeline.length > 0 ? timeline.map((item, i) => { + const height = (item.count / maxVal) * 100; + return ( +
+
+

{item.count} Actions Logged

+

{item.time}

+
+
+ ); + }) : ( +
+ Historical Ledger Empty +
+ )} +
+ +
+
+ Peak Activity + {maxVal === 1 && timeline.every(t => t.count === 0) ? 0 : maxVal} +
+
+ Cumulative total + {data.total || 0} +
+
+
+ ); +}; + +export default UnifiedTimelineViewer; diff --git a/src/presentation/feature/cms/routes/CMSDashboardPage.jsx b/src/presentation/feature/cms/routes/CMSDashboardPage.jsx index d1fb22a..fe3dc4d 100644 --- a/src/presentation/feature/cms/routes/CMSDashboardPage.jsx +++ b/src/presentation/feature/cms/routes/CMSDashboardPage.jsx @@ -1,14 +1,45 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useFetchCMSAnalytics } from '../../../../domain/useCase/useFetchCMSAnalytics'; -import { ShieldAlert, BookOpen, Calendar, Users, TrendingUp } from 'lucide-react'; +import { ShieldAlert, BookOpen, Calendar, Users, TrendingUp, BarChart3, Users2 } from 'lucide-react'; import { PageLoader } from '../../../shared/components/loaders/PageLoader'; import { Link } from 'react-router-dom'; import { PATHS } from '../../../routes/paths'; -const StatCard = ({ title, value, icon: Icon, trend, trendLabel, colorClass }) => ( -
+// Viewers +import UnifiedTimelineViewer from '../components/dashboard/viewers/GrowthTimelineViewer'; +import ContributorStatsViewer from '../components/dashboard/viewers/ContributorStatsViewer'; +import DefaultDashboardViewer from '../components/dashboard/viewers/DefaultDashboardViewer'; + +const VIEWERS = { + DEFAULT: 'default', + COURSES: 'courses', + EVENTS: 'events', + USERS: 'users', + REPORTS: 'reports', + CONTRIBUTORS: 'contributors' +}; + +const TIME_OPTIONS = [ + { label: '7 Days', value: 7 }, + { label: '30 Days', value: 30 }, + { label: '60 Days', value: 60 }, + { label: '90 Days', value: 90 }, +]; + +const StatCard = ({ title, value, icon: Icon, trend, trendLabel, colorClass, onClick, isActive, detailIcon: DetailIcon }) => ( + ); const CMSDashboardPage = () => { - const { stats, isLoading, fetchAnalytics } = useFetchCMSAnalytics(); + const { data, stats, isLoading, fetchAnalytics } = useFetchCMSAnalytics(); + const [activeViewer, setActiveViewer] = useState(VIEWERS.DEFAULT); + const [timeRange, setTimeRange] = useState(60); useEffect(() => { - fetchAnalytics(); - }, [fetchAnalytics]); + fetchAnalytics(timeRange); + }, [fetchAnalytics, timeRange]); + + const renderViewer = () => { + const subtitle = `Temporal velocity over the last ${timeRange} days`; + switch (activeViewer) { + case VIEWERS.COURSES: + return ( + + ); + case VIEWERS.EVENTS: + return ( + + ); + case VIEWERS.USERS: + return ( + + ); + case VIEWERS.REPORTS: + return ( + + ); + case VIEWERS.CONTRIBUTORS: + return ; + default: + return ; + } + }; + + if (isLoading) return
; + + if (!data && error) { + return ( +
+
+ +

Access Restricted or Missing

+

{error}

+
+ +
+ ); + } - if (isLoading) return
; + if (!data) return null; return (
{/* Header */} -
-

Master Ledger

-

A high-level overview of the Academy's current state and pending actions.

+
+
+

Master Ledger

+

+ + System Analytics & Insight Engine +

+
+ +
+ {TIME_OPTIONS.map((opt) => ( + + ))} +
+ +
- {/* Metrics Grid */} + {/* Interactive Metrics Grid */}
setActiveViewer(VIEWERS.COURSES)} /> setActiveViewer(VIEWERS.EVENTS)} /> setActiveViewer(VIEWERS.USERS)} /> setActiveViewer(VIEWERS.REPORTS)} />
- {/* Content Body */} -
- {/* Trend Visualizer (CSS Mock) */} -
-
-

Platform Activity Trend

- Last 30 Days -
- {/* Simulated Chart using CSS grid and pseudo-elements representing a bar chart */} -
- {[40, 60, 30, 80, 50, 70, 90, 45, 65, 85, 55, 75, 40, 60, 100].map((height, i) => ( -
- {/* Tooltip */} -
- {height} units -
-
- ))} -
+ {/* Insight Hub Body */} +
+ {/* Dynamic Insight Viewer */} +
+
+ {renderViewer()}
- {/* Critical Actions */} -
-
- -

Attention Required

- -
- {stats.pendingReports > 0 ? ( -
-
-
- -
-
-

Unresolved Reports

-

{stats.pendingReports} reports await moderation

-
-
- Review Reports + {/* Sidebar Context */} +
+
+
+

System Security

+
+
+ Pending Reports + 0 ? 'bg-error text-ivory' : 'bg-success/20 text-success'}`}> + {stats.pendingReports} +
- ) : ( -
- -

No pending reports. All clear!

-
- )} - -
-
-
- -
-
-

Draft Content

-

Review unpublished materials

-
-
- Go to Courses -
+ + Enter Security Console + +
+
+ +
+ +

More analytics modules coming soon to the Scholar's Ledger.

diff --git a/tests/useCases/useFetchCMSAnalytics.test.js b/tests/useCases/useFetchCMSAnalytics.test.js index 1c30964..85096c5 100644 --- a/tests/useCases/useFetchCMSAnalytics.test.js +++ b/tests/useCases/useFetchCMSAnalytics.test.js @@ -1,78 +1,74 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useFetchCMSAnalytics } from '../../src/domain/useCase/useFetchCMSAnalytics'; +import { AnalyticsRepository } from '../../src/infrastructure/repository/AnalyticsRepository'; -const mockCourseGetAll = vi.fn(); -const mockEventGetAll = vi.fn(); -const mockReportGetAll = vi.fn(); -const mockUserGetAll = vi.fn(); +// Create a stable mock function that can be accessed across instances +const mockGetFullAnalytics = vi.fn(); -vi.mock('@infrastructure/repository/CourseRepository', () => ({ - CourseRepository: class { - constructor() { this.getAll = mockCourseGetAll; } +// Mock the AnalyticsRepository +vi.mock('../../src/infrastructure/repository/AnalyticsRepository', () => ({ + AnalyticsRepository: class { + constructor() { + this.getFullAnalytics = mockGetFullAnalytics; + } } })); -vi.mock('@infrastructure/repository/EventRepository', () => ({ - EventRepository: class { - constructor() { this.getAll = mockEventGetAll; } - } -})); - -vi.mock('@infrastructure/repository/ReportRepository', () => ({ - ReportRepository: class { - constructor() { this.getAll = mockReportGetAll; } - } -})); - -vi.mock('@infrastructure/repository/UserRepository', () => ({ - UserRepository: class { - constructor() { this.getAllUsers = mockUserGetAll; } - } -})); - -describe('useFetchCMSAnalytics Hook', () => { +describe('useFetchCMSAnalytics Hook (Refactored)', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('should successfully aggregate analytics metrics', async () => { - // Mock successful repository responses - mockCourseGetAll.mockResolvedValue({ meta: { pagination: { total: 42 } } }); - mockEventGetAll.mockResolvedValue({ meta: { pagination: { total: 10 } } }); - mockReportGetAll.mockResolvedValue({ meta: { pagination: { total: 5 } } }); - mockUserGetAll.mockResolvedValue(new Array(150).fill({ id: 1 })); // 150 users + it('should successfully fetch and map analytics metrics', async () => { + // Mock raw data return from API + const mockRawData = { + courses: { total: 42, timeline: [] }, + events: { total: 10, timeline: [] }, + users: { total: 150, timeline: [] }, + reports: { total: 5, timeline: [] }, + contributors: { + authors: { total: 5, timeline: [] }, + organizers: { total: 3, timeline: [] } + } + }; + + mockGetFullAnalytics.mockResolvedValue(mockRawData); const { result } = renderHook(() => useFetchCMSAnalytics()); - // Initially loading - expect(result.current.isLoading).toBe(true); - + // Wait for it to settle await act(async () => { - await result.current.fetchAnalytics(); + await result.current.fetchAnalytics(60); }); - expect(mockCourseGetAll).toHaveBeenCalledWith(null, 1, 1); - expect(mockEventGetAll).toHaveBeenCalledWith(1, 1); - expect(mockReportGetAll).toHaveBeenCalledWith(1, 1, '', 'PENDING'); - expect(mockUserGetAll).toHaveBeenCalled(); - + expect(mockGetFullAnalytics).toHaveBeenCalled(); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); + expect(result.current.stats).toEqual({ totalCourses: 42, totalEvents: 10, + totalUsers: 150, pendingReports: 5, - totalUsers: 150 + courseAuthors: 5, + eventOrganizers: 3 }); }); it('should handle zero metrics gracefully', async () => { - // Mock responses with zero counts - mockCourseGetAll.mockResolvedValue({ meta: { pagination: { total: 0 } } }); - mockEventGetAll.mockResolvedValue({}); // Empty object fallback test - mockReportGetAll.mockResolvedValue({ meta: null }); // Null middle field fallback test - mockUserGetAll.mockResolvedValue([]); + const mockRawData = { + courses: { total: 0, timeline: [] }, + events: { total: 0, timeline: [] }, + users: { total: 0, timeline: [] }, + reports: { total: 0, timeline: [] }, + contributors: { + authors: { total: 0, timeline: [] }, + organizers: { total: 0, timeline: [] } + } + }; + + mockGetFullAnalytics.mockResolvedValue(mockRawData); const { result } = renderHook(() => useFetchCMSAnalytics()); @@ -80,22 +76,13 @@ describe('useFetchCMSAnalytics Hook', () => { await result.current.fetchAnalytics(); }); - expect(result.current.isLoading).toBe(false); + expect(result.current.stats.totalCourses).toBe(0); expect(result.current.error).toBeNull(); - expect(result.current.stats).toEqual({ - totalCourses: 0, - totalEvents: 0, - pendingReports: 0, - totalUsers: 0 - }); }); - it('should handle errors thrown by repositories', async () => { + it('should handle repository errors correctly', async () => { const error = new Error('Database connection failed'); - mockCourseGetAll.mockRejectedValue(error); - mockEventGetAll.mockResolvedValue({ meta: { pagination: { total: 10 } } }); - mockReportGetAll.mockResolvedValue({ meta: { pagination: { total: 5 } } }); - mockUserGetAll.mockResolvedValue([]); + mockGetFullAnalytics.mockRejectedValue(error); const { result } = renderHook(() => useFetchCMSAnalytics()); @@ -104,13 +91,8 @@ describe('useFetchCMSAnalytics Hook', () => { }); expect(result.current.isLoading).toBe(false); + // Clean error string should be matched expect(result.current.error).toBe('Database connection failed'); - // Stats should remain at defaults when error occurs during Promise.all - expect(result.current.stats).toEqual({ - totalCourses: 0, - totalEvents: 0, - pendingReports: 0, - totalUsers: 0 - }); + expect(result.current.stats.totalCourses).toBe(0); }); });