@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
589 lines (498 loc) • 22.6 kB
text/typescript
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 { GetLearnWorldsUserProgressAction } from '../providers/learnworlds/actions/get-user-progress.action';
import { ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import { GetUserProgressResult, UserLearningProgress } 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 course progress response
*/
function createRawCourseProgress(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
course_id: 'course-1',
course_title: 'Intro to TypeScript',
enrollment_id: 'enroll-1',
enrolled_at: '2024-01-15T10:00:00Z',
last_accessed_at: '2024-06-01T14:30:00Z',
// percentage is read by mapCourseProgress for progress calculation
percentage: 65,
// progress_percentage is read by determineCourseStatus for status logic
progress_percentage: 65,
completed_lessons: 13,
total_lessons: 20,
completed_units: 3,
total_units: 5,
total_time_spent: 7200,
time_spent: 7200,
average_session_time: 1800,
quiz_score_average: 85,
assignments_completed: 2,
assignments_total: 3,
current_grade: 88,
completed: false,
expired: false,
started: true,
certificate_earned: false,
...overrides,
};
}
/**
* Helper to build a raw LW API enrollment item
*/
function createRawEnrollment(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
course_id: 'course-1',
...overrides,
};
}
/**
* Helper to build a raw LW API unit data
*/
function createRawUnit(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'unit-1',
title: 'Getting Started',
type: 'section',
order: 1,
progress_percentage: 80,
completed_lessons: 4,
total_lessons: 5,
time_spent: 3600,
completed: false,
started: true,
started_at: '2024-01-20T09:00:00Z',
...overrides,
};
}
/**
* Helper to build a raw LW API lesson data
*/
function createRawLesson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'lesson-1',
title: 'Introduction to Variables',
type: 'video',
order: 1,
completed: true,
progress_percentage: 100,
time_spent: 600,
started_at: '2024-01-20T09:00:00Z',
completed_at: '2024-01-20T09:10:00Z',
last_accessed_at: '2024-01-20T09:10:00Z',
video_watch_time: 580,
video_total_time: 600,
...overrides,
};
}
describe('GetLearnWorldsUserProgressAction', () => {
let action: GetLearnWorldsUserProgressAction;
let contextUser: UserInfo;
beforeEach(() => {
action = new GetLearnWorldsUserProgressAction();
contextUser = createMockContextUser();
});
describe('GetUserProgress() typed method', () => {
it('should get single course progress (with courseId)', async () => {
const rawProgress = createRawCourseProgress();
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
);
expect(result.UserProgress.userId).toBe('user-123');
expect(result.UserProgress.userEmail).toBe('user@example.com');
expect(result.UserProgress.totalCourses).toBe(1);
expect(result.UserProgress.courses).toHaveLength(1);
const course = result.UserProgress.courses[0];
expect(course.courseId).toBe('course-1');
expect(course.courseTitle).toBe('Intro to TypeScript');
expect(course.progressPercentage).toBe(65);
expect(course.completedLessons).toBe(13);
expect(course.totalLessons).toBe(20);
expect(course.status).toBe('in_progress');
expect(course.certificateEarned).toBe(false);
expect(course.quizScoreAverage).toBe(85);
});
it('should get all courses progress (no courseId)', async () => {
const enrollment1 = createRawEnrollment({ course_id: 'course-1' });
const enrollment2 = createRawEnrollment({ course_id: 'course-2' });
const progress1 = createRawCourseProgress({ course_id: 'course-1', course_title: 'Course 1', percentage: 100, progress_percentage: 100, completed: true });
const progress2 = createRawCourseProgress({ course_id: 'course-2', course_title: 'Course 2', percentage: 30, progress_percentage: 30 });
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
// getCourseProgress calls for each enrollment
makeRequestSpy
.mockResolvedValueOnce(progress1 as never) // course 1 progress
.mockResolvedValueOnce(progress2 as never) // course 2 progress
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest')
.mockResolvedValue([enrollment1, enrollment2] as never);
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123' },
contextUser,
);
expect(result.UserProgress.totalCourses).toBe(2);
expect(result.UserProgress.courses).toHaveLength(2);
expect(result.UserProgress.coursesCompleted).toBe(1);
expect(result.UserProgress.coursesInProgress).toBe(1);
});
it('should include unit details when IncludeUnitDetails is true', async () => {
const rawProgress = createRawCourseProgress();
const rawUnit = createRawUnit();
const unitsResponse = { data: [rawUnit] };
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce(unitsResponse as never) // units endpoint
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1', IncludeUnitDetails: true },
contextUser,
);
const course = result.UserProgress.courses[0];
expect(course.unitProgress).toBeDefined();
expect(course.unitProgress).toHaveLength(1);
expect(course.unitProgress![0].unitId).toBe('unit-1');
expect(course.unitProgress![0].unitTitle).toBe('Getting Started');
expect(course.unitProgress![0].progressPercentage).toBe(80);
expect(course.unitProgress![0].status).toBe('in_progress');
});
it('should include lesson details when IncludeLessonDetails is true', async () => {
const rawLesson = createRawLesson();
const rawUnit = createRawUnit({ lessons: [rawLesson] });
const rawProgress = createRawCourseProgress();
const unitsResponse = { data: [rawUnit] };
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce(unitsResponse as never) // units endpoint
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1', IncludeUnitDetails: true, IncludeLessonDetails: true },
contextUser,
);
const unit = result.UserProgress.courses[0].unitProgress![0];
expect(unit.lessons).toBeDefined();
expect(unit.lessons).toHaveLength(1);
expect(unit.lessons![0].lessonId).toBe('lesson-1');
expect(unit.lessons![0].lessonTitle).toBe('Introduction to Variables');
expect(unit.lessons![0].lessonType).toBe('video');
expect(unit.lessons![0].completed).toBe(true);
expect(unit.lessons![0].videoWatchTime).toBe(580);
expect(unit.lessons![0].videoTotalTime).toBe(600);
});
it('should detect expired course status', async () => {
const rawProgress = createRawCourseProgress({
expired: true,
expires_at: '2024-01-01T00:00:00Z',
percentage: 50,
progress_percentage: 50,
});
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
);
expect(result.UserProgress.courses[0].status).toBe('expired');
});
it('should return zero counts for empty enrollments', async () => {
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest')
.mockResolvedValue([] as never);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123' },
contextUser,
);
expect(result.UserProgress.totalCourses).toBe(0);
expect(result.UserProgress.coursesCompleted).toBe(0);
expect(result.UserProgress.coursesInProgress).toBe(0);
expect(result.UserProgress.coursesNotStarted).toBe(0);
expect(result.UserProgress.overallProgressPercentage).toBe(0);
expect(result.UserProgress.totalTimeSpent).toBe(0);
expect(result.UserProgress.totalCertificatesEarned).toBe(0);
expect(result.UserProgress.courses).toEqual([]);
});
it('should log warning but not fail when user info fetch fails', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const rawProgress = createRawCourseProgress();
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockRejectedValueOnce(new Error('User not found')); // user info fails
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
);
expect(result.UserProgress.userEmail).toBe('');
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch user info for userId user-123'),
expect.stringContaining('User not found'),
);
consoleWarnSpy.mockRestore();
});
it('should log warning but not fail when units endpoint fails', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const rawProgress = createRawCourseProgress();
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockRejectedValueOnce(new Error('Units not available')) // units endpoint fails
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1', IncludeUnitDetails: true },
contextUser,
);
// Should still return progress, just without unit details
expect(result.UserProgress.courses[0].courseId).toBe('course-1');
expect(result.UserProgress.courses[0].unitProgress).toBeUndefined();
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Units endpoint unavailable for user user-123, course course-1'),
expect.stringContaining('Units not available'),
);
consoleWarnSpy.mockRestore();
});
it('should propagate errors from the API', async () => {
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockRejectedValue(new Error('LearnWorlds API error: 500 Internal Server Error'));
await expect(
action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
),
).rejects.toThrow('LearnWorlds API error: 500 Internal Server Error');
});
it('should calculate analytics (lastLearningDate, learningStreak)', async () => {
const now = new Date();
const recentDate = new Date(now.getTime() - 12 * 60 * 60 * 1000); // 12 hours ago
const rawProgress = createRawCourseProgress({
last_accessed_at: recentDate.toISOString(),
progress_percentage: 75,
started: true,
});
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
);
expect(result.UserProgress.lastLearningDate).toBeDefined();
expect(result.UserProgress.lastLearningDate).toBeInstanceOf(Date);
// Accessed within the last day, so learningStreak should be 1
expect(result.UserProgress.learningStreak).toBe(1);
});
it('should validate summary structure (overview, performance, achievements sections)', async () => {
const rawProgress = createRawCourseProgress({
percentage: 100,
progress_percentage: 100,
completed: true,
completed_at: '2024-05-01T00:00:00Z',
certificate_earned: true,
certificate_url: 'https://example.com/cert',
});
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
);
const summary = result.Summary;
// overview section
expect(summary).toHaveProperty('overview');
const overview = summary['overview'] as Record<string, unknown>;
expect(overview).toHaveProperty('totalCourses');
expect(overview).toHaveProperty('completedCourses');
expect(overview).toHaveProperty('inProgressCourses');
expect(overview).toHaveProperty('notStartedCourses');
expect(overview).toHaveProperty('overallProgress');
expect(overview).toHaveProperty('certificatesEarned');
expect(overview).toHaveProperty('totalLearningTime');
// performance section
expect(summary).toHaveProperty('performance');
const performance = summary['performance'] as Record<string, unknown>;
expect(performance).toHaveProperty('averageQuizScore');
expect(performance).toHaveProperty('averageCourseProgress');
expect(performance).toHaveProperty('completionRate');
// achievements section
expect(summary).toHaveProperty('achievements');
const achievements = summary['achievements'] as Record<string, unknown>;
expect(achievements).toHaveProperty('totalCertificates');
expect(achievements).toHaveProperty('coursesWithCertificates');
// currentFocus and recentActivity sections
expect(summary).toHaveProperty('currentFocus');
expect(summary).toHaveProperty('recentActivity');
});
it('should handle learningStreak as 0 when last access was more than a day ago', async () => {
const oldDate = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
const rawProgress = createRawCourseProgress({
last_accessed_at: oldDate.toISOString(),
});
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
);
expect(result.UserProgress.learningStreak).toBe(0);
});
it('should detect completed course status when progress is 100%', async () => {
const rawProgress = createRawCourseProgress({
percentage: 100,
progress_percentage: 100,
completed: true,
completed_at: '2024-03-01T12:00:00Z',
certificate_earned: true,
});
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
);
const course = result.UserProgress.courses[0];
expect(course.status).toBe('completed');
expect(result.UserProgress.coursesCompleted).toBe(1);
expect(result.UserProgress.totalCertificatesEarned).toBe(1);
});
it('should detect not_started course status', async () => {
const rawProgress = createRawCourseProgress({
percentage: 0,
progress_percentage: 0,
completed: false,
started: false,
expired: false,
});
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(rawProgress as never) // course progress
.mockResolvedValueOnce({ email: 'user@example.com' } as never); // user info
const result = await action.GetUserProgress(
{ CompanyID: 'comp-1', UserID: 'user-123', CourseID: 'course-1' },
contextUser,
);
expect(result.UserProgress.courses[0].status).toBe('not_started');
expect(result.UserProgress.coursesNotStarted).toBe(1);
});
});
describe('InternalRunAction()', () => {
it('should return success when GetUserProgress succeeds', async () => {
const mockProgress: UserLearningProgress = {
userId: 'user-123',
userEmail: 'user@example.com',
totalCourses: 2,
coursesCompleted: 1,
coursesInProgress: 1,
coursesNotStarted: 0,
overallProgressPercentage: 75,
totalTimeSpent: 14400,
totalCertificatesEarned: 1,
courses: [],
};
vi.spyOn(action, 'GetUserProgress').mockResolvedValue({
UserProgress: mockProgress,
Summary: { overview: {}, performance: {}, achievements: {} },
});
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'UserID', Type: 'Input', Value: 'user-123' },
{ Name: 'CourseID', Type: 'Input', Value: undefined },
{ Name: 'IncludeUnitDetails', Type: 'Input', Value: false },
{ Name: 'IncludeLessonDetails', Type: 'Input', Value: false },
],
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('Successfully retrieved progress for 2 courses');
});
it('should return success with course-specific message when CourseID is provided', async () => {
const mockProgress: UserLearningProgress = {
userId: 'user-123',
userEmail: 'user@example.com',
totalCourses: 1,
coursesCompleted: 0,
coursesInProgress: 1,
coursesNotStarted: 0,
overallProgressPercentage: 50,
totalTimeSpent: 3600,
totalCertificatesEarned: 0,
courses: [],
};
vi.spyOn(action, 'GetUserProgress').mockResolvedValue({
UserProgress: mockProgress,
Summary: { overview: {}, performance: {}, achievements: {} },
});
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'UserID', Type: 'Input', Value: 'user-123' },
{ Name: 'CourseID', Type: 'Input', Value: 'course-42' },
{ Name: 'IncludeUnitDetails', Type: 'Input', Value: false },
{ Name: 'IncludeLessonDetails', Type: 'Input', Value: false },
],
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('Successfully retrieved progress for course course-42');
});
it('should return error result when GetUserProgress throws', async () => {
vi.spyOn(action, 'GetUserProgress').mockRejectedValue(new Error('API connection failed'));
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'UserID', Type: 'Input', Value: 'user-123' },
],
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('API connection failed');
});
});
});