UNPKG

@memberjunction/actions-bizapps-lms

Version:

LMS system integration actions for MemberJunction

456 lines (395 loc) 16.4 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock dependencies (same pattern as lms.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 { OnboardLearnerAction } from '../providers/learnworlds/actions/onboard-learner.action'; import { CreateUserAction } from '../providers/learnworlds/actions/create-user.action'; import { EnrollUserAction } from '../providers/learnworlds/actions/enroll-user.action'; import { SSOLoginAction } from '../providers/learnworlds/actions/sso-login.action'; import { ActionResultSimple, RunActionParams } from '@memberjunction/actions-base'; import { CreateUserResult } from '../providers/learnworlds/interfaces/user.types'; import { EnrollUserResult } from '../providers/learnworlds/interfaces/enrollment.types'; import { SSOLoginResult } from '../providers/learnworlds/interfaces/sso.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 CreateUserResult */ function createMockCreateUserResult(overrides: Partial<{ id: string; loginUrl: string }> = {}): CreateUserResult { return { UserDetails: { id: overrides.id ?? 'new-user-id', email: 'learner@example.com', fullName: 'New Learner', role: 'student', status: 'active', tags: [], customFields: {}, loginUrl: overrides.loginUrl ?? 'https://school.com/login/abc', }, EnrollmentResults: [], Summary: { userId: overrides.id ?? 'new-user-id', email: 'learner@example.com', fullName: 'New Learner', role: 'student', status: 'active', welcomeEmailSent: false, coursesEnrolled: 0, totalCoursesRequested: 0, }, }; } /** * Helper to build a mock EnrollUserResult */ function createMockEnrollResult(enrollmentId: string): EnrollUserResult { return { EnrollmentDetails: { id: enrollmentId, userId: 'new-user-id', courseId: 'course-1', enrolledAt: '2024-06-15T10:00:00Z', status: 'active', price: 0, progress: { percentage: 0, completedUnits: 0, totalUnits: 10 }, certificateEligible: false, }, Summary: { enrollmentId, userId: 'new-user-id', userName: 'learner@example.com', courseId: 'course-1', courseTitle: 'Intro Course', enrolledAt: '2024-06-15T10:00:00Z', status: 'active', price: 0, notificationSent: false, }, }; } /** * Helper to build a mock SSOLoginResult */ function createMockSSOResult(): SSOLoginResult { return { LoginURL: 'https://school.com/sso/xyz', LearnWorldsUserID: 'lw-user-id', }; } describe('OnboardLearnerAction', () => { let action: OnboardLearnerAction; let contextUser: UserInfo; beforeEach(() => { action = new OnboardLearnerAction(); contextUser = createMockContextUser(); }); describe('OnboardLearner() typed method', () => { it('should onboard a new learner (create user + enroll + SSO)', async () => { // Step 1: User not found by email => create new user vi.spyOn(action, 'FindUserByEmail').mockResolvedValue(null); vi.spyOn(CreateUserAction.prototype, 'CreateUser').mockResolvedValue(createMockCreateUserResult()); // Step 2: Enroll in one course vi.spyOn(EnrollUserAction.prototype, 'EnrollUser').mockResolvedValue(createMockEnrollResult('enr-001')); // Step 3: Generate SSO URL vi.spyOn(SSOLoginAction.prototype, 'GenerateSSOUrl').mockResolvedValue(createMockSSOResult()); // SetCompanyContext is called on the SSO action instance; mock it to be a no-op vi.spyOn(SSOLoginAction.prototype, 'SetCompanyContext').mockImplementation(() => {}); const result = await action.OnboardLearner( { CompanyID: 'comp-1', Email: 'learner@example.com', FirstName: 'New', LastName: 'Learner', CourseIDs: ['course-1'], }, contextUser, ); expect(result.Success).toBe(true); expect(result.IsNewUser).toBe(true); expect(result.LearnWorldsUserId).toBe('new-user-id'); expect(result.LoginURL).toBe('https://school.com/sso/xyz'); expect(result.Enrollments).toHaveLength(1); expect(result.Enrollments[0].success).toBe(true); expect(result.Enrollments[0].enrollmentId).toBe('enr-001'); expect(result.Errors).toEqual([]); }); it('should onboard an existing learner (find user + enroll + SSO)', async () => { // Step 1: User found by email vi.spyOn(action, 'FindUserByEmail').mockResolvedValue({ id: 'existing-user-id', email: 'learner@example.com', username: 'learner', status: 'active', role: 'student', createdAt: new Date('2024-01-01'), }); // Step 2: Enroll in one course vi.spyOn(EnrollUserAction.prototype, 'EnrollUser').mockResolvedValue(createMockEnrollResult('enr-002')); // Step 3: Generate SSO URL vi.spyOn(SSOLoginAction.prototype, 'GenerateSSOUrl').mockResolvedValue(createMockSSOResult()); vi.spyOn(SSOLoginAction.prototype, 'SetCompanyContext').mockImplementation(() => {}); const result = await action.OnboardLearner( { CompanyID: 'comp-1', Email: 'learner@example.com', CourseIDs: ['course-1'], }, contextUser, ); expect(result.Success).toBe(true); expect(result.IsNewUser).toBe(false); expect(result.LearnWorldsUserId).toBe('existing-user-id'); expect(result.LoginURL).toBe('https://school.com/sso/xyz'); expect(result.Enrollments).toHaveLength(1); expect(result.Enrollments[0].success).toBe(true); expect(result.Errors).toEqual([]); }); it('should handle enrollment failures as partial success', async () => { // User found vi.spyOn(action, 'FindUserByEmail').mockResolvedValue({ id: 'user-id', email: 'learner@example.com', username: 'learner', status: 'active', role: 'student', createdAt: new Date('2024-01-01'), }); // First enrollment succeeds, second fails vi.spyOn(EnrollUserAction.prototype, 'EnrollUser') .mockResolvedValueOnce(createMockEnrollResult('enr-ok')) .mockRejectedValueOnce(new Error('Course not found')); // SSO succeeds vi.spyOn(SSOLoginAction.prototype, 'GenerateSSOUrl').mockResolvedValue(createMockSSOResult()); vi.spyOn(SSOLoginAction.prototype, 'SetCompanyContext').mockImplementation(() => {}); const result = await action.OnboardLearner( { CompanyID: 'comp-1', Email: 'learner@example.com', CourseIDs: ['course-1', 'course-bad'], }, contextUser, ); // Not all enrollments failed, so overall Success is true expect(result.Success).toBe(true); expect(result.Enrollments).toHaveLength(2); expect(result.Enrollments[0].success).toBe(true); expect(result.Enrollments[1].success).toBe(false); expect(result.Enrollments[1].error).toContain('Course not found'); expect(result.Errors).toHaveLength(1); expect(result.Errors[0]).toContain('Enrollment failed for course course-bad'); }); it('should handle all enrollments failing (Success is false)', async () => { vi.spyOn(action, 'FindUserByEmail').mockResolvedValue({ id: 'user-id', email: 'learner@example.com', username: 'learner', status: 'active', role: 'student', createdAt: new Date('2024-01-01'), }); vi.spyOn(EnrollUserAction.prototype, 'EnrollUser').mockRejectedValue(new Error('Server error')); vi.spyOn(SSOLoginAction.prototype, 'GenerateSSOUrl').mockResolvedValue(createMockSSOResult()); vi.spyOn(SSOLoginAction.prototype, 'SetCompanyContext').mockImplementation(() => {}); const result = await action.OnboardLearner( { CompanyID: 'comp-1', Email: 'learner@example.com', CourseIDs: ['course-1'], }, contextUser, ); // All enrollments failed expect(result.Success).toBe(false); expect(result.Enrollments).toHaveLength(1); expect(result.Enrollments[0].success).toBe(false); expect(result.Errors).toHaveLength(1); }); it('should handle SSO failure with fallback URL from user creation', async () => { // New user created with a loginUrl fallback vi.spyOn(action, 'FindUserByEmail').mockResolvedValue(null); vi.spyOn(CreateUserAction.prototype, 'CreateUser').mockResolvedValue(createMockCreateUserResult({ loginUrl: 'https://school.com/fallback-login' })); // No enrollments // SSO fails vi.spyOn(SSOLoginAction.prototype, 'GenerateSSOUrl').mockRejectedValue(new Error('SSO service unavailable')); vi.spyOn(SSOLoginAction.prototype, 'SetCompanyContext').mockImplementation(() => {}); const result = await action.OnboardLearner( { CompanyID: 'comp-1', Email: 'learner@example.com', }, contextUser, ); expect(result.Success).toBe(true); expect(result.IsNewUser).toBe(true); // Falls back to the loginUrl from user creation expect(result.LoginURL).toBe('https://school.com/fallback-login'); expect(result.Enrollments).toEqual([]); expect(result.Errors).toEqual([]); }); it('should handle SSO failure with no fallback for existing users', async () => { // Existing user (no loginUrl fallback) vi.spyOn(action, 'FindUserByEmail').mockResolvedValue({ id: 'existing-id', email: 'learner@example.com', username: 'learner', status: 'active', role: 'student', createdAt: new Date('2024-01-01'), }); // SSO fails vi.spyOn(SSOLoginAction.prototype, 'GenerateSSOUrl').mockRejectedValue(new Error('SSO service unavailable')); vi.spyOn(SSOLoginAction.prototype, 'SetCompanyContext').mockImplementation(() => {}); const result = await action.OnboardLearner( { CompanyID: 'comp-1', Email: 'learner@example.com', }, contextUser, ); expect(result.Success).toBe(true); // No fallback available for existing users expect(result.LoginURL).toBeUndefined(); }); it('should enroll in both courses and bundles', async () => { vi.spyOn(action, 'FindUserByEmail').mockResolvedValue({ id: 'user-id', email: 'learner@example.com', username: 'learner', status: 'active', role: 'student', createdAt: new Date('2024-01-01'), }); vi.spyOn(EnrollUserAction.prototype, 'EnrollUser').mockResolvedValue(createMockEnrollResult('enr-multi')); vi.spyOn(SSOLoginAction.prototype, 'GenerateSSOUrl').mockResolvedValue(createMockSSOResult()); vi.spyOn(SSOLoginAction.prototype, 'SetCompanyContext').mockImplementation(() => {}); const result = await action.OnboardLearner( { CompanyID: 'comp-1', Email: 'learner@example.com', CourseIDs: ['course-1'], BundleIDs: ['bundle-1'], }, contextUser, ); expect(result.Success).toBe(true); expect(result.Enrollments).toHaveLength(2); expect(result.Enrollments[0].productType).toBe('course'); expect(result.Enrollments[1].productType).toBe('bundle'); }); }); describe('InternalRunAction()', () => { it('should return success when OnboardLearner succeeds', async () => { vi.spyOn(action, 'OnboardLearner').mockResolvedValue({ Success: true, LoginURL: 'https://school.com/sso/xyz', LearnWorldsUserId: 'lw-user-1', IsNewUser: true, Enrollments: [{ productId: 'course-1', productType: 'course', success: true, enrollmentId: 'enr-1' }], Errors: [], }); const runParams: RunActionParams = { Params: [ { Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }, { Name: 'Email', Type: 'Input', Value: 'learner@example.com' }, { Name: 'CourseIDs', Type: 'Input', Value: ['course-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('Onboarding complete'); expect(result.Message).toContain('new user created'); expect(result.Message).toContain('1/1 enrollment(s) succeeded'); }); it('should return PARTIAL_FAILURE when some enrollments fail', async () => { vi.spyOn(action, 'OnboardLearner').mockResolvedValue({ Success: false, LoginURL: 'https://school.com/sso/xyz', LearnWorldsUserId: 'lw-user-1', IsNewUser: false, Enrollments: [{ productId: 'course-1', productType: 'course', success: false, error: 'Course not found' }], Errors: ['Course not found'], }); const runParams: RunActionParams = { Params: [ { Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }, { Name: 'Email', Type: 'Input', Value: 'learner@example.com' }, { Name: 'CourseIDs', Type: 'Input', Value: ['course-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('Onboarding partially failed'); expect(result.Message).toContain('existing user found'); expect(result.Message).toContain('0/1 enrollment(s) succeeded'); }); it('should return error when email is missing', async () => { const runParams: RunActionParams = { Params: [ { Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }, { Name: 'Email', 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("'Email'"); }); it('should return ERROR when OnboardLearner throws', async () => { vi.spyOn(action, 'OnboardLearner').mockRejectedValue(new Error('User creation succeeded but no user ID was returned')); const runParams: RunActionParams = { Params: [ { Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }, { Name: 'Email', Type: 'Input', Value: 'learner@example.com' }, ], 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 onboarding'); expect(result.Message).toContain('no user ID was returned'); }); }); });