@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
534 lines (464 loc) • 21 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 { GetLearnWorldsUserDetailsAction } from '../providers/learnworlds/actions/get-user-details.action';
import { ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import { LearnWorldsUserDetailsFull, UserDetailEnrollment } 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 user response matching LWUserDetailsResponse
*/
function createRawApiUser(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'user-abc-123',
_id: 'user-abc-123',
email: 'alice@example.com',
username: 'alice',
first_name: 'Alice',
last_name: 'Smith',
full_name: 'Alice Smith',
status: 'active',
role: 'student',
created: '2024-01-10T10:00:00Z',
created_at: '2024-01-10T10:00:00Z',
last_login: '2024-06-15T08:30:00Z',
avatar_url: 'https://example.com/avatar.jpg',
profile_image: 'https://example.com/profile.jpg',
bio: 'Learning enthusiast',
description: 'A great learner',
location: 'New York',
country: 'US',
timezone: 'America/New_York',
language: 'en',
phone: '+1234567890',
tags: ['vip', 'beta'],
custom_fields: { department: 'Engineering' },
last_activity: '2024-06-20T12:00:00Z',
certificates_count: 3,
badges_count: 5,
points: 1200,
level: 'Gold',
email_notifications: true,
two_factor_enabled: false,
agreed_to_terms: true,
marketing_consent: false,
...overrides,
};
}
/**
* Helper to build a raw LW enrollment data object
*/
function createRawEnrollment(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
course_id: 'course-1',
course: { id: 'course-1', title: 'Intro to TypeScript' },
course_title: 'Intro to TypeScript',
enrolled_at: '2024-02-01T09:00:00Z',
created: '2024-02-01T09:00:00Z',
active: true,
completed: false,
expired: false,
suspended: false,
progress: { percentage: 60, completed_units: 6, total_units: 10, time_spent: 3600 },
completed_at: undefined,
expires_at: '2025-02-01T09:00:00Z',
last_accessed_at: '2024-06-18T14:00:00Z',
time_spent: 3600,
certificate_url: undefined,
grade: 85,
final_grade: undefined,
...overrides,
};
}
describe('GetLearnWorldsUserDetailsAction', () => {
let action: GetLearnWorldsUserDetailsAction;
let contextUser: UserInfo;
beforeEach(() => {
action = new GetLearnWorldsUserDetailsAction();
contextUser = createMockContextUser();
});
describe('GetUserDetails() typed method', () => {
it('should get user details with enrollments and stats', async () => {
const rawUser = createRawApiUser();
const rawEnrollmentCompleted = createRawEnrollment({
course_id: 'course-2',
course_title: 'Advanced Node.js',
active: false,
completed: true,
progress: { percentage: 100, completed_units: 20, total_units: 20, time_spent: 7200 },
completed_at: '2024-05-10T16:00:00Z',
last_accessed_at: '2024-05-10T16:00:00Z',
time_spent: 7200,
certificate_url: 'https://example.com/cert/node',
grade: 95,
});
const rawEnrollmentActive = createRawEnrollment();
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
// First call: user details
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
// Second call: enrollments
makeRequestSpy.mockResolvedValueOnce({ data: [rawEnrollmentActive, rawEnrollmentCompleted] } as never);
// Third call: stats
makeRequestSpy.mockResolvedValueOnce({
total_time_spent: 15000,
certificates_earned: 4,
badges_earned: 7,
points: 1500,
} as never);
const result = await action.GetUserDetails(
{ CompanyID: 'comp-1', UserID: 'user-abc-123', IncludeEnrollments: true, IncludeStats: true },
contextUser,
);
expect(result.UserDetails).toBeDefined();
expect(result.UserDetails.id).toBe('user-abc-123');
expect(result.UserDetails.email).toBe('alice@example.com');
expect(result.UserDetails.username).toBe('alice');
expect(result.UserDetails.firstName).toBe('Alice');
expect(result.UserDetails.lastName).toBe('Smith');
expect(result.UserDetails.fullName).toBe('Alice Smith');
expect(result.UserDetails.status).toBe('active');
expect(result.UserDetails.role).toBe('student');
expect(result.UserDetails.language).toBe('en');
expect(result.UserDetails.phone).toBe('+1234567890');
expect(result.UserDetails.tags).toEqual(['vip', 'beta']);
expect(result.UserDetails.customFields).toEqual({ department: 'Engineering' });
expect(result.UserDetails.totalCertificates).toBe(4); // overridden by stats
expect(result.UserDetails.totalBadges).toBe(7); // overridden by stats
expect(result.UserDetails.points).toBe(1500); // overridden by stats
expect(result.UserDetails.level).toBe('Gold');
expect(result.UserDetails.emailNotifications).toBe(true);
expect(result.UserDetails.twoFactorEnabled).toBe(false);
expect(result.UserDetails.agreedToTerms).toBe(true);
expect(result.UserDetails.marketingConsent).toBe(false);
// Enrollments should be populated
expect(result.UserDetails.enrollments).toBeDefined();
expect(result.UserDetails.enrollments).toHaveLength(2);
// Stats overridden by stats endpoint
expect(result.UserDetails.totalTimeSpent).toBe(15000);
// Summary should be present
expect(result.Summary).toBeDefined();
expect(result.Summary.userId).toBe('user-abc-123');
expect(result.Summary.displayName).toBe('Alice Smith');
expect(result.Summary.status).toBe('active');
expect(result.Summary.role).toBe('student');
});
it('should get user details without enrollments when IncludeEnrollments is false', async () => {
const rawUser = createRawApiUser();
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
// First call: user details
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
// Second call: stats (enrollments skipped)
makeRequestSpy.mockResolvedValueOnce({ total_time_spent: 5000 } as never);
const result = await action.GetUserDetails(
{ CompanyID: 'comp-1', UserID: 'user-abc-123', IncludeEnrollments: false, IncludeStats: true },
contextUser,
);
expect(result.UserDetails.enrollments).toBeUndefined();
// Stats should still load
expect(result.UserDetails.totalTimeSpent).toBe(5000);
// Only 2 API calls: user + stats (no enrollments)
expect(makeRequestSpy).toHaveBeenCalledTimes(2);
});
it('should get user details without stats when IncludeStats is false', async () => {
const rawUser = createRawApiUser();
const rawEnrollment = createRawEnrollment();
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
// First call: user details
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
// Second call: enrollments
makeRequestSpy.mockResolvedValueOnce({ data: [rawEnrollment] } as never);
const result = await action.GetUserDetails(
{ CompanyID: 'comp-1', UserID: 'user-abc-123', IncludeEnrollments: true, IncludeStats: false },
contextUser,
);
expect(result.UserDetails.enrollments).toHaveLength(1);
// Only 2 API calls: user + enrollments (no stats)
expect(makeRequestSpy).toHaveBeenCalledTimes(2);
});
it('should throw error when UserID is missing', async () => {
await expect(
action.GetUserDetails({ CompanyID: 'comp-1', UserID: '' }, contextUser),
).rejects.toThrow('UserID parameter is required');
});
it('should calculate enrollment stats correctly', async () => {
const rawUser = createRawApiUser();
const completedEnrollment = createRawEnrollment({
course_id: 'c1',
course_title: 'Course 1',
active: false,
completed: true,
progress: { percentage: 100, completed_units: 10, total_units: 10, time_spent: 5000 },
time_spent: 5000,
});
const inProgressEnrollment = createRawEnrollment({
course_id: 'c2',
course_title: 'Course 2',
active: true,
completed: false,
progress: { percentage: 50, completed_units: 5, total_units: 10, time_spent: 2500 },
time_spent: 2500,
});
const notStartedEnrollment = createRawEnrollment({
course_id: 'c3',
course_title: 'Course 3',
active: true,
completed: false,
progress: { percentage: 0, completed_units: 0, total_units: 10, time_spent: 0 },
time_spent: 0,
});
const expiredEnrollment = createRawEnrollment({
course_id: 'c4',
course_title: 'Course 4',
active: false,
completed: false,
expired: true,
progress: { percentage: 30, completed_units: 3, total_units: 10, time_spent: 1200 },
time_spent: 1200,
});
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
makeRequestSpy.mockResolvedValueOnce({
data: [completedEnrollment, inProgressEnrollment, notStartedEnrollment, expiredEnrollment],
} as never);
// Stats endpoint (won't override enrollment-computed stats except totalTimeSpent)
makeRequestSpy.mockResolvedValueOnce({} as never);
const result = await action.GetUserDetails(
{ CompanyID: 'comp-1', UserID: 'user-abc-123' },
contextUser,
);
// totalCourses = 4
expect(result.UserDetails.totalCourses).toBe(4);
// completedCourses: only status === 'completed' => 1
expect(result.UserDetails.completedCourses).toBe(1);
// inProgressCourses: status === 'active' && progress > 0 && progress < 100 => 1
expect(result.UserDetails.inProgressCourses).toBe(1);
// notStartedCourses: status === 'active' && progress === 0 => 1
expect(result.UserDetails.notStartedCourses).toBe(1);
// totalTimeSpent: sum of all enrollments (5000 + 2500 + 0 + 1200) = 8700
expect(result.UserDetails.totalTimeSpent).toBe(8700);
// averageCompletionRate: active+completed enrollments => c1(100)+c2(50)+c3(0) => 150/3 = 50
expect(result.UserDetails.averageCompletionRate).toBe(50);
});
it('should log warning and continue when stats endpoint fails', async () => {
const rawUser = createRawApiUser();
const rawEnrollment = createRawEnrollment();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
makeRequestSpy.mockResolvedValueOnce({ data: [rawEnrollment] } as never);
makeRequestSpy.mockRejectedValueOnce(new Error('Stats API unavailable') as never);
const result = await action.GetUserDetails(
{ CompanyID: 'comp-1', UserID: 'user-abc-123', IncludeEnrollments: true, IncludeStats: true },
contextUser,
);
// Should still succeed despite stats failure
expect(result.UserDetails).toBeDefined();
expect(result.UserDetails.email).toBe('alice@example.com');
expect(result.UserDetails.enrollments).toHaveLength(1);
// Should have logged the warning
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Stats endpoint unavailable for user user-abc-123'),
expect.stringContaining('Stats API unavailable'),
);
warnSpy.mockRestore();
});
it('should merge stats data correctly via extractStatistics', async () => {
const rawUser = createRawApiUser({ certificates_count: 2, badges_count: 3, points: 100 });
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
// Stats response should override the user-level fields
makeRequestSpy.mockResolvedValueOnce({
total_time_spent: 99999,
certificates_earned: 10,
badges_earned: 20,
points: 5000,
} as never);
const result = await action.GetUserDetails(
{ CompanyID: 'comp-1', UserID: 'user-abc-123', IncludeEnrollments: false, IncludeStats: true },
contextUser,
);
// Stats endpoint values should override initial mapUserDetails values
expect(result.UserDetails.totalTimeSpent).toBe(99999);
expect(result.UserDetails.totalCertificates).toBe(10);
expect(result.UserDetails.totalBadges).toBe(20);
expect(result.UserDetails.points).toBe(5000);
});
it('should build summary structure with correct shape', async () => {
const rawUser = createRawApiUser();
const enrollment = createRawEnrollment({
last_accessed_at: '2024-06-18T14:00:00Z',
});
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
makeRequestSpy.mockResolvedValueOnce({ data: [enrollment] } as never);
makeRequestSpy.mockResolvedValueOnce({} as never);
const result = await action.GetUserDetails(
{ CompanyID: 'comp-1', UserID: 'user-abc-123' },
contextUser,
);
const summary = result.Summary;
expect(summary.userId).toBe('user-abc-123');
expect(summary.displayName).toBe('Alice Smith');
expect(summary.status).toBe('active');
expect(summary.role).toBe('student');
// learningProgress section
const learningProgress = summary.learningProgress as Record<string, unknown>;
expect(learningProgress).toBeDefined();
expect(learningProgress.totalCourses).toBeDefined();
expect(learningProgress.completedCourses).toBeDefined();
expect(learningProgress.inProgressCourses).toBeDefined();
expect(learningProgress.notStartedCourses).toBeDefined();
expect(learningProgress.averageCompletionRate).toBeDefined();
expect(learningProgress.totalTimeSpent).toBeDefined();
// achievements section
const achievements = summary.achievements as Record<string, unknown>;
expect(achievements).toBeDefined();
expect(achievements.certificates).toBeDefined();
expect(achievements.badges).toBeDefined();
expect(achievements.points).toBeDefined();
expect(achievements.level).toBe('Gold');
// engagement section
const engagement = summary.engagement as Record<string, unknown>;
expect(engagement).toBeDefined();
expect(typeof engagement.accountAge).toBe('number');
expect(engagement.lastLoginDaysAgo).not.toBeNull();
expect(engagement.lastActivityDaysAgo).not.toBeNull();
// recentActivity section
const recentActivity = summary.recentActivity as Array<Record<string, unknown>>;
expect(recentActivity).toBeDefined();
expect(Array.isArray(recentActivity)).toBe(true);
expect(recentActivity.length).toBeGreaterThanOrEqual(1);
expect(recentActivity[0].courseTitle).toBeDefined();
expect(recentActivity[0].progress).toBeDefined();
expect(recentActivity[0].lastAccessed).toBeDefined();
});
it('should default IncludeEnrollments and IncludeStats to true', async () => {
const rawUser = createRawApiUser();
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
makeRequestSpy.mockResolvedValueOnce({ data: [] } as never); // enrollments
makeRequestSpy.mockResolvedValueOnce({} as never); // stats
await action.GetUserDetails({ CompanyID: 'comp-1', UserID: 'user-abc-123' }, contextUser);
// Should have made 3 calls: user + enrollments + stats
expect(makeRequestSpy).toHaveBeenCalledTimes(3);
});
it('should use _id fallback when id is not present', async () => {
const rawUser = createRawApiUser({ id: undefined, _id: 'alt-user-id' });
const makeRequestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest');
makeRequestSpy.mockResolvedValueOnce(rawUser as never);
makeRequestSpy.mockResolvedValueOnce({} as never); // stats
const result = await action.GetUserDetails(
{ CompanyID: 'comp-1', UserID: 'alt-user-id', IncludeEnrollments: false, IncludeStats: true },
contextUser,
);
expect(result.UserDetails.id).toBe('alt-user-id');
});
});
describe('InternalRunAction()', () => {
it('should return success when GetUserDetails succeeds', async () => {
const mockDetails: LearnWorldsUserDetailsFull = {
id: 'user-abc-123',
email: 'alice@example.com',
username: 'alice',
firstName: 'Alice',
lastName: 'Smith',
fullName: 'Alice Smith',
status: 'active',
role: 'student',
createdAt: new Date('2024-01-10T10:00:00Z'),
lastLoginAt: new Date('2024-06-15T08:30:00Z'),
totalCourses: 2,
completedCourses: 1,
inProgressCourses: 1,
notStartedCourses: 0,
totalTimeSpent: 5000,
averageCompletionRate: 75,
totalCertificates: 1,
totalBadges: 2,
points: 500,
};
vi.spyOn(action, 'GetUserDetails').mockResolvedValue({
UserDetails: mockDetails,
Summary: { userId: 'user-abc-123', displayName: 'Alice Smith' },
});
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'UserID', Type: 'Input', Value: 'user-abc-123' },
{ Name: 'IncludeEnrollments', Type: 'Input', Value: true },
{ Name: 'IncludeStats', Type: 'Input', Value: true },
],
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 details for user alice@example.com');
});
it('should return error result when GetUserDetails throws', async () => {
vi.spyOn(action, 'GetUserDetails').mockRejectedValue(new Error('User not found in LearnWorlds'));
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'UserID', Type: 'Input', Value: 'nonexistent-user' },
],
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('User not found in LearnWorlds');
});
it('should return error when ContextUser is missing', async () => {
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'UserID', Type: 'Input', Value: 'user-abc-123' },
],
ContextUser: undefined,
} 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('Context user is required');
});
});
});