@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
650 lines (596 loc) • 23 kB
text/typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock dependencies (same pattern as onboard-learner.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 { GetLearnWorldsBulkDataAction } from '../providers/learnworlds/actions/get-bulk-data.action';
import { GetLearnWorldsUsersAction } from '../providers/learnworlds/actions/get-users.action';
import { GetLearnWorldsCoursesAction } from '../providers/learnworlds/actions/get-courses.action';
import { GetBundlesAction } from '../providers/learnworlds/actions/get-bundles.action';
import { GetUserEnrollmentsAction } from '../providers/learnworlds/actions/get-user-enrollments.action';
import { GetCertificatesAction } from '../providers/learnworlds/actions/get-certificates.action';
import { GetLearnWorldsUserProgressAction } from '../providers/learnworlds/actions/get-user-progress.action';
import { GetQuizResultsAction } from '../providers/learnworlds/actions/get-quiz-results.action';
import { ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import { GetUsersResult } from '../providers/learnworlds/interfaces/user.types';
import { GetCoursesResult, GetUserProgressResult } from '../providers/learnworlds/interfaces/course.types';
import { GetBundlesResult } from '../providers/learnworlds/interfaces/bundle.types';
import { GetUserEnrollmentsResult } from '../providers/learnworlds/interfaces/enrollment.types';
import { GetCertificatesResult } from '../providers/learnworlds/interfaces/certificate.types';
import { GetQuizResultsResult } from '../providers/learnworlds/interfaces/quiz.types';
/**
* 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 mock GetUsersResult
*/
function createMockUsersResult(): GetUsersResult {
return {
Users: [
{
id: 'user-1',
email: 'alice@example.com',
username: 'alice',
status: 'active' as const,
role: 'student',
createdAt: new Date('2024-01-01'),
},
{
id: 'user-2',
email: 'bob@example.com',
username: 'bob',
status: 'active' as const,
role: 'student',
createdAt: new Date('2024-02-01'),
},
],
TotalCount: 2,
Summary: {
totalUsers: 2,
activeUsers: 2,
inactiveUsers: 0,
suspendedUsers: 0,
usersByRole: { student: 2 },
averageCoursesPerUser: 0,
totalTimeSpent: 0,
mostActiveUsers: [],
recentSignups: [],
},
};
}
/**
* Helper to build a mock GetCoursesResult
*/
function createMockCoursesResult(): GetCoursesResult {
return {
Courses: [
{
id: 'course-1',
title: 'Intro to Testing',
status: 'published' as const,
visibility: 'public' as const,
isActive: true,
isFree: true,
totalUnits: 10,
totalLessons: 8,
totalQuizzes: 2,
totalAssignments: 0,
totalEnrollments: 50,
activeEnrollments: 30,
completionRate: 60,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-06-01'),
requiresApproval: false,
hasPrerequisites: false,
certificateAvailable: true,
},
],
TotalCount: 1,
Summary: {
totalCourses: 1,
publishedCourses: 1,
draftCourses: 0,
freeCourses: 1,
paidCourses: 0,
categoryCounts: {},
levelCounts: {},
languageCounts: {},
enrollmentStats: { totalEnrollments: 50, averageEnrollmentsPerCourse: 50, mostPopularCourses: [] },
priceStats: { averagePrice: 0, minPrice: 0, maxPrice: 0, currency: 'USD' },
},
};
}
/**
* Helper to build a mock GetBundlesResult
*/
function createMockBundlesResult(): GetBundlesResult {
return {
Bundles: [
{
id: 'bundle-1',
title: 'Starter Bundle',
courses: ['course-1'],
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-06-01T00:00:00Z',
totalCourses: 1,
totalEnrollments: 10,
},
],
TotalCount: 1,
};
}
/**
* Helper to build a mock GetUserEnrollmentsResult
*/
function createMockEnrollmentsResult(): GetUserEnrollmentsResult {
return {
Enrollments: [
{
id: 'enr-1',
courseId: 'course-1',
enrolledAt: '2024-03-01T00:00:00Z',
status: 'active',
progress: {
percentage: 50,
completedUnits: 5,
totalUnits: 10,
completedLessons: 4,
totalLessons: 8,
totalTimeSpent: 3600,
totalTimeSpentText: '1h 0m 0s',
},
certificateEligible: false,
},
],
TotalCount: 1,
Summary: {
userId: 'user-1',
totalEnrollments: 1,
activeEnrollments: 1,
completedEnrollments: 0,
expiredEnrollments: 0,
inProgressEnrollments: 1,
averageProgressPercentage: 50,
totalTimeSpent: 3600,
totalTimeSpentText: '1h 0m 0s',
certificatesEarned: 0,
enrollmentsByStatus: { active: 1, completed: 0, expired: 0 },
},
};
}
/**
* Helper to build a mock GetCertificatesResult
*/
function createMockCertificatesResult(): GetCertificatesResult {
return {
Certificates: [
{
id: 'cert-1',
userId: 'user-1',
courseId: 'course-1',
certificateNumber: 'CERT-001',
issuedAt: '2024-06-01T00:00:00Z',
status: 'active',
completionPercentage: 100,
verification: { url: undefined, code: undefined, qrCode: undefined },
},
],
TotalCount: 1,
Summary: {
totalCertificates: 1,
activeCertificates: 1,
expiredCertificates: 0,
dateRange: { from: 'all-time', to: 'current' },
filterType: 'user',
groupedData: null,
},
};
}
/**
* Helper to build a mock GetQuizResultsResult
*/
function createMockQuizResultsResult(): GetQuizResultsResult {
return {
QuizResults: [
{
id: 'qr-1',
userId: 'user-1',
courseId: 'course-1',
quizId: 'quiz-1',
attemptNumber: 1,
score: 85,
maxScore: 100,
percentage: 85,
passed: true,
passingScore: 70,
completedAt: '2024-05-15T10:00:00Z',
duration: 1800,
durationText: '30m 0s',
},
],
TotalCount: 1,
Summary: {
totalResults: 1,
passedResults: 1,
failedResults: 0,
passRate: '100.0',
averageScore: '85.0',
averageDuration: 1800,
averageDurationText: '30m 0s',
dateRange: { from: 'all-time', to: 'current' },
filterType: 'all',
quizBreakdown: null,
},
};
}
/**
* Helper to build a mock GetUserProgressResult
*/
function createMockProgressResult(userId: string): GetUserProgressResult {
return {
UserProgress: {
userId,
userEmail: `${userId}@example.com`,
totalCourses: 1,
coursesCompleted: 0,
coursesInProgress: 1,
coursesNotStarted: 0,
overallProgressPercentage: 50,
totalTimeSpent: 3600,
totalCertificatesEarned: 0,
courses: [
{
courseId: 'course-1',
courseTitle: 'Intro to Testing',
enrollmentId: 'enr-1',
enrolledAt: new Date('2024-03-01'),
progressPercentage: 50,
completedUnits: 5,
totalUnits: 10,
completedLessons: 4,
totalLessons: 8,
totalTimeSpent: 3600,
averageSessionTime: 600,
status: 'in_progress',
certificateEarned: false,
},
],
},
Summary: {
overview: {
totalCourses: 1,
completedCourses: 0,
inProgressCourses: 1,
notStartedCourses: 0,
overallProgress: '50%',
certificatesEarned: 0,
totalLearningTime: '1h 0m',
},
performance: {
averageQuizScore: 'N/A',
averageCourseProgress: '50%',
completionRate: '0%',
},
currentFocus: [],
recentActivity: [],
achievements: { totalCertificates: 0, coursesWithCertificates: [] },
},
};
}
describe('GetLearnWorldsBulkDataAction', () => {
let action: GetLearnWorldsBulkDataAction;
let contextUser: UserInfo;
beforeEach(() => {
action = new GetLearnWorldsBulkDataAction();
contextUser = createMockContextUser();
vi.restoreAllMocks();
});
describe('GetBulkData() typed method', () => {
it('should fetch all data types when no Include flags are set (default all)', async () => {
vi.spyOn(GetLearnWorldsUsersAction.prototype, 'GetUsers').mockResolvedValue(createMockUsersResult());
vi.spyOn(GetLearnWorldsCoursesAction.prototype, 'GetCourses').mockResolvedValue(createMockCoursesResult());
vi.spyOn(GetBundlesAction.prototype, 'GetBundles').mockResolvedValue(createMockBundlesResult());
vi.spyOn(GetUserEnrollmentsAction.prototype, 'GetUserEnrollments').mockResolvedValue(createMockEnrollmentsResult());
vi.spyOn(GetLearnWorldsUserProgressAction.prototype, 'GetUserProgress').mockImplementation(async (params) =>
createMockProgressResult(params.UserID),
);
vi.spyOn(GetCertificatesAction.prototype, 'GetCertificates').mockResolvedValue(createMockCertificatesResult());
vi.spyOn(GetQuizResultsAction.prototype, 'GetQuizResults').mockResolvedValue(createMockQuizResultsResult());
const result = await action.GetBulkData({ CompanyID: 'comp-1' }, contextUser);
expect(result.users).toHaveLength(2);
expect(result.courses).toHaveLength(1);
expect(result.bundles).toHaveLength(1);
expect(result.enrollments).toBeDefined();
expect(result.progress).toHaveLength(2);
expect(result.certificates).toHaveLength(1);
expect(result.quizResults).toHaveLength(1);
expect(result.companyId).toBe('comp-1');
expect(result.syncedAt).toBeDefined();
expect(result.totalApiCalls).toBeGreaterThanOrEqual(0);
expect(result.errors).toEqual([]);
});
it('should only fetch users and courses when selectively requested', async () => {
vi.spyOn(GetLearnWorldsUsersAction.prototype, 'GetUsers').mockResolvedValue(createMockUsersResult());
vi.spyOn(GetLearnWorldsCoursesAction.prototype, 'GetCourses').mockResolvedValue(createMockCoursesResult());
const result = await action.GetBulkData(
{
CompanyID: 'comp-1',
IncludeUsers: true,
IncludeCourses: true,
IncludeBundles: false,
IncludeEnrollments: false,
IncludeProgress: false,
IncludeCertificates: false,
IncludeQuizResults: false,
},
contextUser,
);
expect(result.users).toHaveLength(2);
expect(result.courses).toHaveLength(1);
expect(result.bundles).toBeUndefined();
expect(result.enrollments).toBeUndefined();
expect(result.progress).toBeUndefined();
expect(result.certificates).toBeUndefined();
expect(result.quizResults).toBeUndefined();
expect(result.errors).toEqual([]);
});
it('should collect errors on partial failure without aborting', async () => {
vi.spyOn(GetLearnWorldsUsersAction.prototype, 'GetUsers').mockResolvedValue(createMockUsersResult());
vi.spyOn(GetLearnWorldsCoursesAction.prototype, 'GetCourses').mockRejectedValue(new Error('Courses API unavailable'));
vi.spyOn(GetBundlesAction.prototype, 'GetBundles').mockResolvedValue(createMockBundlesResult());
vi.spyOn(GetUserEnrollmentsAction.prototype, 'GetUserEnrollments').mockResolvedValue(createMockEnrollmentsResult());
vi.spyOn(GetLearnWorldsUserProgressAction.prototype, 'GetUserProgress').mockImplementation(async (params) =>
createMockProgressResult(params.UserID),
);
vi.spyOn(GetCertificatesAction.prototype, 'GetCertificates').mockResolvedValue(createMockCertificatesResult());
vi.spyOn(GetQuizResultsAction.prototype, 'GetQuizResults').mockResolvedValue(createMockQuizResultsResult());
const result = await action.GetBulkData({ CompanyID: 'comp-1' }, contextUser);
expect(result.users).toHaveLength(2);
expect(result.courses).toEqual([]);
expect(result.bundles).toHaveLength(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].entity).toBe('course');
expect(result.errors[0].message).toContain('Courses API unavailable');
expect(result.errors[0].operation).toBe('read');
});
it('should collect all errors when all fetches fail', async () => {
vi.spyOn(GetLearnWorldsUsersAction.prototype, 'GetUsers').mockRejectedValue(new Error('Users API down'));
vi.spyOn(GetLearnWorldsCoursesAction.prototype, 'GetCourses').mockRejectedValue(new Error('Courses API down'));
vi.spyOn(GetBundlesAction.prototype, 'GetBundles').mockRejectedValue(new Error('Bundles API down'));
vi.spyOn(GetCertificatesAction.prototype, 'GetCertificates').mockRejectedValue(new Error('Certificates API down'));
vi.spyOn(GetQuizResultsAction.prototype, 'GetQuizResults').mockRejectedValue(new Error('Quiz API down'));
const result = await action.GetBulkData({ CompanyID: 'comp-1' }, contextUser);
// Users failed, so enrollments and progress won't have users to iterate over
expect(result.users).toEqual([]);
expect(result.courses).toEqual([]);
expect(result.bundles).toEqual([]);
expect(result.enrollments).toBeDefined();
expect(result.progress).toBeDefined();
expect(result.certificates).toEqual([]);
expect(result.quizResults).toEqual([]);
// At minimum: user + course + bundle + certificate + quizResult errors
expect(result.errors.length).toBeGreaterThanOrEqual(5);
});
it('should return empty arrays when no data exists', async () => {
const emptyUsersResult: GetUsersResult = {
Users: [],
TotalCount: 0,
Summary: {
totalUsers: 0,
activeUsers: 0,
inactiveUsers: 0,
suspendedUsers: 0,
usersByRole: {},
averageCoursesPerUser: 0,
totalTimeSpent: 0,
mostActiveUsers: [],
recentSignups: [],
},
};
const emptyCoursesResult: GetCoursesResult = {
Courses: [],
TotalCount: 0,
Summary: {
totalCourses: 0,
publishedCourses: 0,
draftCourses: 0,
freeCourses: 0,
paidCourses: 0,
categoryCounts: {},
levelCounts: {},
languageCounts: {},
enrollmentStats: { totalEnrollments: 0, averageEnrollmentsPerCourse: 0, mostPopularCourses: [] },
priceStats: { averagePrice: 0, minPrice: 0, maxPrice: 0, currency: 'USD' },
},
};
const emptyBundlesResult: GetBundlesResult = { Bundles: [], TotalCount: 0 };
const emptyCertsResult: GetCertificatesResult = {
Certificates: [],
TotalCount: 0,
Summary: {
totalCertificates: 0,
activeCertificates: 0,
expiredCertificates: 0,
dateRange: { from: 'all-time', to: 'current' },
filterType: 'user',
groupedData: null,
},
};
const emptyQuizResult: GetQuizResultsResult = {
QuizResults: [],
TotalCount: 0,
Summary: {
totalResults: 0,
passedResults: 0,
failedResults: 0,
passRate: 0,
averageScore: '0',
averageDuration: 0,
averageDurationText: '0s',
dateRange: { from: 'all-time', to: 'current' },
filterType: 'all',
quizBreakdown: null,
},
};
vi.spyOn(GetLearnWorldsUsersAction.prototype, 'GetUsers').mockResolvedValue(emptyUsersResult);
vi.spyOn(GetLearnWorldsCoursesAction.prototype, 'GetCourses').mockResolvedValue(emptyCoursesResult);
vi.spyOn(GetBundlesAction.prototype, 'GetBundles').mockResolvedValue(emptyBundlesResult);
// No users → progress won't be called per-user, so no mock needed (returns empty)
vi.spyOn(GetCertificatesAction.prototype, 'GetCertificates').mockResolvedValue(emptyCertsResult);
vi.spyOn(GetQuizResultsAction.prototype, 'GetQuizResults').mockResolvedValue(emptyQuizResult);
const result = await action.GetBulkData({ CompanyID: 'comp-1' }, contextUser);
expect(result.users).toEqual([]);
expect(result.courses).toEqual([]);
expect(result.bundles).toEqual([]);
expect(result.enrollments).toEqual([]);
expect(result.progress).toEqual([]);
expect(result.certificates).toEqual([]);
expect(result.quizResults).toEqual([]);
expect(result.errors).toEqual([]);
});
it('should fetch users for enrollments even if IncludeUsers is false but IncludeEnrollments is true', async () => {
vi.spyOn(GetLearnWorldsUsersAction.prototype, 'GetUsers').mockResolvedValue(createMockUsersResult());
vi.spyOn(GetUserEnrollmentsAction.prototype, 'GetUserEnrollments').mockResolvedValue(createMockEnrollmentsResult());
const result = await action.GetBulkData(
{
CompanyID: 'comp-1',
IncludeUsers: false,
IncludeCourses: false,
IncludeBundles: false,
IncludeEnrollments: true,
IncludeProgress: false,
IncludeCertificates: false,
IncludeQuizResults: false,
},
contextUser,
);
// Users were fetched (needed for enrollments) but not included in output
expect(result.users).toBeUndefined();
expect(result.enrollments).toBeDefined();
expect(result.enrollments!.length).toBeGreaterThan(0);
});
it('should fetch users for progress even if IncludeUsers is false but IncludeProgress is true', async () => {
vi.spyOn(GetLearnWorldsUsersAction.prototype, 'GetUsers').mockResolvedValue(createMockUsersResult());
vi.spyOn(GetLearnWorldsUserProgressAction.prototype, 'GetUserProgress').mockImplementation(async (params) =>
createMockProgressResult(params.UserID),
);
const result = await action.GetBulkData(
{
CompanyID: 'comp-1',
IncludeUsers: false,
IncludeCourses: false,
IncludeBundles: false,
IncludeEnrollments: false,
IncludeProgress: true,
IncludeCertificates: false,
IncludeQuizResults: false,
},
contextUser,
);
// Users were fetched (needed for progress) but not included in output
expect(result.users).toBeUndefined();
expect(result.progress).toBeDefined();
expect(result.progress!.length).toBe(2);
expect(result.progress![0].userId).toBe('user-1');
expect(result.progress![1].userId).toBe('user-2');
});
});
describe('InternalRunAction()', () => {
it('should return success when GetBulkData succeeds', async () => {
vi.spyOn(action, 'GetBulkData').mockResolvedValue({
users: [{ id: 'u1', email: 'a@b.com', username: 'a', status: 'active', role: 'student', createdAt: new Date() }],
courses: [],
syncedAt: new Date().toISOString(),
companyId: 'comp-1',
totalApiCalls: 2,
errors: [],
});
const runParams: RunActionParams = {
Params: [{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }],
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('Bulk data retrieval complete');
});
it('should return PARTIAL_FAILURE when there are errors', async () => {
vi.spyOn(action, 'GetBulkData').mockResolvedValue({
users: [],
courses: [],
syncedAt: new Date().toISOString(),
companyId: 'comp-1',
totalApiCalls: 3,
errors: [
{ entity: 'course', entityId: '', operation: 'read', message: 'Courses API down', timestamp: new Date().toISOString() },
],
});
const runParams: RunActionParams = {
Params: [{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }],
ContextUser: contextUser,
} as unknown as RunActionParams;
const result = (await action['InternalRunAction'](runParams)) as ActionResultSimple;
expect(result.Success).toBe(false);
expect(result.ResultCode).toBe('PARTIAL_FAILURE');
expect(result.Message).toContain('partially succeeded');
expect(result.Message).toContain('1 error(s)');
});
it('should return error when CompanyID is missing', async () => {
const runParams: RunActionParams = {
Params: [{ Name: 'CompanyID', Type: 'Input', Value: undefined }],
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("'CompanyID'");
});
it('should return ERROR when GetBulkData throws', async () => {
vi.spyOn(action, 'GetBulkData').mockRejectedValue(new Error('Unexpected system failure'));
const runParams: RunActionParams = {
Params: [{ Name: 'CompanyID', Type: 'Input', Value: 'comp-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 during bulk data retrieval');
expect(result.Message).toContain('Unexpected system failure');
});
});
});