UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

388 lines (328 loc) 16 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock dependencies (same pattern as get-bundles.action.test.ts) vi.mock('@memberjunction/actions', () => ({ BaseAction: class BaseAction { protected async InternalRunAction(): Promise<unknown> { return {}; } }, })); vi.mock('@memberjunction/global', () => ({ RegisterClass: () => (target: unknown) => target, })); vi.mock('@memberjunction/core', () => ({ UserInfo: class UserInfo {}, Metadata: vi.fn(), RunView: vi.fn().mockImplementation(() => ({ RunView: vi.fn().mockResolvedValue({ Success: true, Results: [] }), })), })); vi.mock('@memberjunction/core-entities', () => ({ MJCompanyIntegrationEntity: class MJCompanyIntegrationEntity { CompanyID: string = ''; APIKey: string | null = null; AccessToken: string | null = null; ExternalSystemID: string | null = null; CustomAttribute1: string | null = null; }, })); vi.mock('@memberjunction/actions-base', () => ({ ActionParam: class ActionParam { Name: string = ''; Value: unknown = null; Type: string = 'Input'; }, })); import { UserInfo } from '@memberjunction/core'; import { GetUserEnrollmentsAction } from '../providers/learnworlds/actions/get-user-enrollments.action'; import { ActionResultSimple, RunActionParams } from '@memberjunction/actions-base'; import { FormattedEnrollment, EnrollmentsSummary } from '../providers/learnworlds/interfaces'; /** * Helper to create a mock UserInfo for test context */ function createMockContextUser(): UserInfo { return { ID: 'test-user-id', Name: 'Test User', Email: 'test@example.com' } as unknown as UserInfo; } /** * Helper to build a raw LW API enrollment object matching the LWApiEnrollmentData interface */ function createRawApiEnrollment(overrides: Record<string, unknown> = {}): Record<string, unknown> { return { id: 'enrollment-1', course_id: 'course-1', enrolled_at: '2024-06-15T10:00:00Z', starts_at: '2024-06-15T10:00:00Z', expires_at: '2025-06-15T10:00:00Z', status: 'active', progress_percentage: 50, completed_units: 5, total_units: 10, completed_lessons: 3, total_lessons: 6, last_accessed_at: '2024-07-01T08:00:00Z', total_time_spent: 7200, grade: 85, certificate_eligible: true, certificate_issued_at: undefined, completed_at: undefined, ...overrides, }; } /** * Helper to build a raw LW API course details object */ function createRawApiCourse(overrides: Record<string, unknown> = {}): Record<string, unknown> { return { id: 'course-1', title: 'Intro to Testing', short_description: 'Learn testing basics', description: 'A comprehensive course on testing.', image_url: 'https://example.com/course.jpg', level: 'beginner', duration: 3600, instructor_name: 'Jane Doe', certificate_enabled: true, ...overrides, }; } describe('GetUserEnrollmentsAction', () => { let action: GetUserEnrollmentsAction; let contextUser: UserInfo; beforeEach(() => { action = new GetUserEnrollmentsAction(); contextUser = createMockContextUser(); }); describe('GetUserEnrollments() typed method', () => { it('should get enrollments successfully with enrollment data', async () => { const rawEnrollment = createRawApiEnrollment(); const rawCourse = createRawApiCourse(); vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawEnrollment] as never); vi.spyOn(action as never, 'makeLearnWorldsRequest').mockResolvedValue(rawCourse as never); const result = await action.GetUserEnrollments({ CompanyID: 'comp-1', UserID: 'user-1' }, contextUser); expect(result.TotalCount).toBe(1); expect(result.Enrollments).toHaveLength(1); const enrollment: FormattedEnrollment = result.Enrollments[0]; expect(enrollment.id).toBe('enrollment-1'); expect(enrollment.courseId).toBe('course-1'); expect(enrollment.enrolledAt).toBe('2024-06-15T10:00:00Z'); expect(enrollment.status).toBe('active'); expect(enrollment.progress.percentage).toBe(50); expect(enrollment.progress.completedUnits).toBe(5); expect(enrollment.progress.totalUnits).toBe(10); expect(enrollment.progress.completedLessons).toBe(3); expect(enrollment.progress.totalLessons).toBe(6); expect(enrollment.progress.totalTimeSpent).toBe(7200); expect(enrollment.grade).toBe(85); expect(enrollment.certificateEligible).toBe(true); // Summary should be populated const summary: EnrollmentsSummary = result.Summary; expect(summary.userId).toBe('user-1'); expect(summary.totalEnrollments).toBe(1); expect(summary.activeEnrollments).toBe(1); }); it('should map enrollment statuses correctly in the summary', async () => { const activeEnrollment = createRawApiEnrollment({ id: 'e-1', course_id: 'c-1', status: 'active', progress_percentage: 30 }); const completedEnrollment = createRawApiEnrollment({ id: 'e-2', course_id: 'c-2', status: 'completed', progress_percentage: 100, completed_at: '2024-07-10T12:00:00Z', certificate_issued_at: '2024-07-11T00:00:00Z', }); const expiredEnrollment = createRawApiEnrollment({ id: 'e-3', course_id: 'c-3', status: 'expired', progress_percentage: 20 }); const suspendedEnrollment = createRawApiEnrollment({ id: 'e-4', course_id: 'c-4', status: 'suspended', progress_percentage: 10 }); vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue( [activeEnrollment, completedEnrollment, expiredEnrollment, suspendedEnrollment] as never, ); vi.spyOn(action as never, 'makeLearnWorldsRequest').mockResolvedValue(createRawApiCourse() as never); const result = await action.GetUserEnrollments( { CompanyID: 'comp-1', UserID: 'user-1', IncludeCourseDetails: true }, contextUser, ); expect(result.TotalCount).toBe(4); const summary = result.Summary; expect(summary.activeEnrollments).toBe(1); // completedEnrollments counts either progress >= 100 or completedAt non-null expect(summary.completedEnrollments).toBe(1); expect(summary.expiredEnrollments).toBe(1); expect(summary.enrollmentsByStatus.active).toBe(1); expect(summary.enrollmentsByStatus.completed).toBe(1); expect(summary.enrollmentsByStatus.expired).toBe(1); expect(summary.certificatesEarned).toBe(1); // averageProgress = (30 + 100 + 20 + 10) / 4 = 40 expect(summary.averageProgressPercentage).toBe(40); }); it('should include course details when IncludeCourseDetails is true', async () => { const rawEnrollment = createRawApiEnrollment(); const rawCourse = createRawApiCourse(); vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawEnrollment] as never); vi.spyOn(action as never, 'makeLearnWorldsRequest').mockResolvedValue(rawCourse as never); const result = await action.GetUserEnrollments( { CompanyID: 'comp-1', UserID: 'user-1', IncludeCourseDetails: true }, contextUser, ); const enrollment = result.Enrollments[0]; expect(enrollment.course).toBeDefined(); expect(enrollment.course!.id).toBe('course-1'); expect(enrollment.course!.title).toBe('Intro to Testing'); expect(enrollment.course!.description).toBe('Learn testing basics'); expect(enrollment.course!.imageUrl).toBe('https://example.com/course.jpg'); expect(enrollment.course!.level).toBe('beginner'); expect(enrollment.course!.duration).toBe(3600); expect(enrollment.course!.instructorName).toBe('Jane Doe'); expect(enrollment.course!.certificateEnabled).toBe(true); }); it('should exclude course details when IncludeCourseDetails is false', async () => { const rawEnrollment = createRawApiEnrollment(); vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawEnrollment] as never); const requestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest'); const result = await action.GetUserEnrollments( { CompanyID: 'comp-1', UserID: 'user-1', IncludeCourseDetails: false }, contextUser, ); expect(result.Enrollments[0].course).toBeUndefined(); // Should not call the course detail endpoint when IncludeCourseDetails is false expect(requestSpy).not.toHaveBeenCalled(); }); it('should handle empty enrollments', async () => { vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never); const result = await action.GetUserEnrollments({ CompanyID: 'comp-1', UserID: 'user-1' }, contextUser); expect(result.TotalCount).toBe(0); expect(result.Enrollments).toEqual([]); expect(result.Summary.totalEnrollments).toBe(0); expect(result.Summary.activeEnrollments).toBe(0); expect(result.Summary.completedEnrollments).toBe(0); expect(result.Summary.expiredEnrollments).toBe(0); expect(result.Summary.averageProgressPercentage).toBe(0); expect(result.Summary.certificatesEarned).toBe(0); }); it('should propagate errors from the API call', async () => { vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockRejectedValue(new Error('Network timeout') as never); await expect(action.GetUserEnrollments({ CompanyID: 'comp-1', UserID: 'user-1' }, contextUser)).rejects.toThrow('Network timeout'); }); it('should throw when UserID is missing', async () => { await expect( action.GetUserEnrollments({ CompanyID: 'comp-1', UserID: '' }, contextUser), ).rejects.toThrow('UserID is required'); }); it('should default status to active when raw enrollment has no status', async () => { const rawEnrollment = createRawApiEnrollment({ status: undefined }); vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawEnrollment] as never); vi.spyOn(action as never, 'makeLearnWorldsRequest').mockResolvedValue(createRawApiCourse() as never); const result = await action.GetUserEnrollments({ CompanyID: 'comp-1', UserID: 'user-1' }, contextUser); expect(result.Enrollments[0].status).toBe('active'); }); it('should use created_at as fallback for enrolledAt when enrolled_at is missing', async () => { const rawEnrollment = createRawApiEnrollment({ enrolled_at: undefined, created_at: '2024-05-01T00:00:00Z' }); vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawEnrollment] as never); vi.spyOn(action as never, 'makeLearnWorldsRequest').mockResolvedValue(createRawApiCourse() as never); const result = await action.GetUserEnrollments({ CompanyID: 'comp-1', UserID: 'user-1' }, contextUser); expect(result.Enrollments[0].enrolledAt).toBe('2024-05-01T00:00:00Z'); }); it('should default progress fields to 0 when missing from the raw data', async () => { const rawEnrollment = createRawApiEnrollment({ progress_percentage: undefined, completed_units: undefined, total_units: undefined, completed_lessons: undefined, total_lessons: undefined, total_time_spent: undefined, }); vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawEnrollment] as never); vi.spyOn(action as never, 'makeLearnWorldsRequest').mockResolvedValue(createRawApiCourse() as never); const result = await action.GetUserEnrollments({ CompanyID: 'comp-1', UserID: 'user-1' }, contextUser); const progress = result.Enrollments[0].progress; expect(progress.percentage).toBe(0); expect(progress.completedUnits).toBe(0); expect(progress.totalUnits).toBe(0); expect(progress.completedLessons).toBe(0); expect(progress.totalLessons).toBe(0); expect(progress.totalTimeSpent).toBe(0); }); it('should calculate inProgressEnrollments correctly in summary', async () => { const enrollments = [ createRawApiEnrollment({ id: 'e-1', course_id: 'c-1', status: 'active', progress_percentage: 50 }), createRawApiEnrollment({ id: 'e-2', course_id: 'c-2', status: 'active', progress_percentage: 100, completed_at: '2024-08-01T00:00:00Z' }), createRawApiEnrollment({ id: 'e-3', course_id: 'c-3', status: 'expired', progress_percentage: 10 }), ]; vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(enrollments as never); vi.spyOn(action as never, 'makeLearnWorldsRequest').mockResolvedValue(createRawApiCourse() as never); const result = await action.GetUserEnrollments({ CompanyID: 'comp-1', UserID: 'user-1' }, contextUser); // total=3, completed=1, expired=1 => inProgress = max(0, 3 - 1 - 1) = 1 expect(result.Summary.inProgressEnrollments).toBe(1); }); }); describe('InternalRunAction()', () => { it('should return success when GetUserEnrollments succeeds', async () => { const mockEnrollments: FormattedEnrollment[] = [ { id: 'enrollment-1', courseId: 'course-1', enrolledAt: '2024-06-15T10:00:00Z', status: 'active', progress: { percentage: 50, completedUnits: 5, totalUnits: 10, completedLessons: 3, totalLessons: 6, totalTimeSpent: 7200, totalTimeSpentText: '2h 0m 0s', }, certificateEligible: false, }, ]; const mockSummary: EnrollmentsSummary = { userId: 'user-1', totalEnrollments: 1, activeEnrollments: 1, completedEnrollments: 0, expiredEnrollments: 0, inProgressEnrollments: 1, averageProgressPercentage: 50, totalTimeSpent: 7200, totalTimeSpentText: '2h 0m 0s', certificatesEarned: 0, enrollmentsByStatus: { active: 1, completed: 0, expired: 0 }, }; vi.spyOn(action, 'GetUserEnrollments').mockResolvedValue({ Enrollments: mockEnrollments, TotalCount: 1, Summary: mockSummary, }); const runParams: RunActionParams = { Params: [ { Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }, { Name: 'UserID', Type: 'Input', Value: 'user-1' }, { Name: 'Status', Type: 'Input', Value: undefined }, { Name: 'IncludeExpired', Type: 'Input', Value: false }, { Name: 'IncludeCourseDetails', Type: 'Input', Value: true }, { Name: 'SortBy', Type: 'Input', Value: undefined }, { Name: 'SortOrder', Type: 'Input', Value: undefined }, { Name: 'MaxResults', Type: 'Input', Value: undefined }, ], ContextUser: contextUser, } as unknown as RunActionParams; const result = (await action['InternalRunAction'](runParams)) as ActionResultSimple; expect(result.Success).toBe(true); expect(result.ResultCode).toBe('SUCCESS'); expect(result.Message).toContain('Retrieved 1 enrollments for user'); }); it('should return error result when GetUserEnrollments throws', async () => { vi.spyOn(action, 'GetUserEnrollments').mockRejectedValue(new Error('API connection failed')); const runParams: RunActionParams = { Params: [ { Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }, { Name: 'UserID', Type: 'Input', Value: 'user-1' }, ], ContextUser: contextUser, } as unknown as RunActionParams; const result = (await action['InternalRunAction'](runParams)) as ActionResultSimple; expect(result.Success).toBe(false); expect(result.ResultCode).toBe('ERROR'); expect(result.Message).toContain('Error retrieving enrollments'); expect(result.Message).toContain('API connection failed'); }); }); });