@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
760 lines (651 loc) • 34.3 kB
text/typescript
/**
* LearnWorlds Integration Tests — hits the REAL LearnWorlds API.
*
* Covers ALL 19 LearnWorlds actions chained together:
* Read-only (no setup needed): GetCourses, GetCourseDetails, GetCourseAnalytics, GetBundles, GetUsers
* Create/mutate: CreateUser, UpdateUser, AttachTags, DetachTags
* Enrollment: EnrollUser, GetUserEnrollments, GetUserProgress, UpdateUserProgress
* Assessment & Achievement: GetCertificates, GetQuizResults
* Auth: SSOLogin
* Details: GetUserDetails
* Orchestration: OnboardLearner
* Lookup: FindUserByEmail
*
* PREREQUISITES:
* 1. Copy .env.integration.example -> .env.integration and fill in real values
* 2. Run: npm run test:integration
*
* These tests are SKIPPED by default (the `describe.skipIf` guard).
* They only run when LW_INTEGRATION=true is set in the environment.
*/
import { describe, it, expect, vi } from 'vitest';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
// ---------- load .env.integration if present ----------
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, '../../.env.integration') });
const RUN_INTEGRATION = process.env.LW_INTEGRATION === 'true';
// ---------- mock only the MJ framework plumbing (not fetch) ----------
vi.mock('@memberjunction/actions', () => ({
BaseAction: class BaseAction {
protected async InternalRunAction(): Promise<unknown> {
return {};
}
},
}));
vi.mock('@memberjunction/global', () => ({
RegisterClass: () => (target: unknown) => target,
UUIDsEqual: (a: string, b: string) => a?.toLowerCase() === b?.toLowerCase(),
}));
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 = '';
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 = '';
Value: unknown = null;
Type = 'Input';
},
}));
// ---------- imports (after mocks) ----------
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 { GetLearnWorldsUsersAction } from '../providers/learnworlds/actions/get-users.action';
import { GetLearnWorldsUserDetailsAction } from '../providers/learnworlds/actions/get-user-details.action';
import { GetUserEnrollmentsAction } from '../providers/learnworlds/actions/get-user-enrollments.action';
import { GetLearnWorldsUserProgressAction } from '../providers/learnworlds/actions/get-user-progress.action';
import { GetLearnWorldsCoursesAction } from '../providers/learnworlds/actions/get-courses.action';
import { GetLearnWorldsCourseDetailsAction } from '../providers/learnworlds/actions/get-course-details.action';
import { GetBundlesAction } from '../providers/learnworlds/actions/get-bundles.action';
import { UpdateUserAction } from '../providers/learnworlds/actions/update-user.action';
import { AttachTagsAction } from '../providers/learnworlds/actions/attach-tags.action';
import { DetachTagsAction } from '../providers/learnworlds/actions/detach-tags.action';
import { OnboardLearnerAction } from '../providers/learnworlds/actions/onboard-learner.action';
import { GetLearnWorldsBulkDataAction } from '../providers/learnworlds/actions/get-bulk-data.action';
import { GetCourseAnalyticsAction } from '../providers/learnworlds/actions/get-course-analytics.action';
import { GetCertificatesAction } from '../providers/learnworlds/actions/get-certificates.action';
import { GetQuizResultsAction } from '../providers/learnworlds/actions/get-quiz-results.action';
import { UpdateUserProgressAction } from '../providers/learnworlds/actions/update-user-progress.action';
import { LearnWorldsBaseAction } from '../providers/learnworlds/learnworlds-base.action';
import { UserInfo } from '@memberjunction/core';
// ---------- env-driven config ----------
const SCHOOL_DOMAIN = process.env.LW_SCHOOL_DOMAIN || '';
const API_KEY = process.env.LW_API_KEY || '';
const CLIENT_ID = process.env.LW_CLIENT_ID || '';
const COMPANY_ID = process.env.LW_COMPANY_ID || '00000000-0000-0000-0000-000000000001';
const TEST_COURSE_ID = process.env.LW_TEST_COURSE_ID || '';
const TEST_LESSON_ID = process.env.LW_TEST_LESSON_ID || '';
const TEST_EMAIL = process.env.LW_TEST_EMAIL || `mj-test-${Date.now()}@example.com`;
const ONBOARD_EMAIL = `mj-onboard-${Date.now()}@example.com`;
/**
* Helper: patch `getCompanyIntegration` and `getAPICredentials` so we
* bypass the database and use env-var credentials directly.
*/
function patchCredentials<T extends LearnWorldsBaseAction>(action: T): T {
const a = action as Record<string, unknown>;
a['getCompanyIntegration'] = async () => ({
CompanyID: COMPANY_ID,
APIKey: API_KEY,
ExternalSystemID: SCHOOL_DOMAIN,
AccessToken: null,
CustomAttribute1: null,
});
a['getAPICredentials'] = async () => ({
apiKey: API_KEY,
apiSecret: undefined,
accessToken: undefined,
});
// Seed the CLIENT_ID env var so buildRequestConfig resolves it
const provider = 'LEARNWORLDS';
process.env[`BIZAPPS_${provider}_${COMPANY_ID}_CLIENT_ID`] = CLIENT_ID || SCHOOL_DOMAIN;
return action;
}
function mockContextUser(): UserInfo {
return { ID: 'integration-test-user' } as unknown as UserInfo;
}
// ──────────────────────────────────────────────────────────────────────
// Integration suite — only runs when LW_INTEGRATION=true
// ──────────────────────────────────────────────────────────────────────
describe.skipIf(!RUN_INTEGRATION)('LearnWorlds Integration Tests', () => {
const contextUser = mockContextUser();
let createdUserId: string | undefined;
let onboardedUserId: string | undefined;
// ═══════════════════════════════════════════════════════════════════
// PHASE 1: Read-only actions (no setup needed)
// ═══════════════════════════════════════════════════════════════════
describe('Phase 1: Read-only actions', () => {
// ─── GetCourses ──────────────────────────────────────────────────
it('GetCourses — should list courses', async () => {
const action = patchCredentials(new GetLearnWorldsCoursesAction());
const result = await action.GetCourses({ CompanyID: COMPANY_ID, MaxResults: 5 }, contextUser);
expect(result.Courses).toBeDefined();
expect(Array.isArray(result.Courses)).toBe(true);
console.log(` GetCourses: OK (${result.TotalCount} courses returned)`);
});
// ─── GetCourseDetails ────────────────────────────────────────────
it('GetCourseDetails — should get course details', async () => {
if (!TEST_COURSE_ID) {
console.warn(' Skipping GetCourseDetails — LW_TEST_COURSE_ID not set');
return;
}
const action = patchCredentials(new GetLearnWorldsCourseDetailsAction());
const result = await action.GetCourseDetails(
{ CompanyID: COMPANY_ID, CourseID: TEST_COURSE_ID, IncludeModules: false, IncludeInstructors: false, IncludeStats: false },
contextUser,
);
expect(result.CourseDetails).toBeDefined();
expect(result.CourseDetails.id).toBeTruthy();
console.log(` GetCourseDetails: OK (title: "${result.CourseDetails.title}")`);
});
// ─── GetCourseAnalytics ────────────────────────────────────────────
it('GetCourseAnalytics — should get course analytics', async () => {
if (!TEST_COURSE_ID) {
console.warn(' Skipping GetCourseAnalytics — LW_TEST_COURSE_ID not set');
return;
}
const action = patchCredentials(new GetCourseAnalyticsAction());
const result = await action.GetCourseAnalytics(
{
CompanyID: COMPANY_ID,
CourseID: TEST_COURSE_ID,
IncludeUserBreakdown: false,
IncludeModuleStats: false,
IncludeRevenue: false,
},
contextUser,
);
expect(result.CourseAnalytics).toBeDefined();
expect(result.Summary).toBeDefined();
console.log(` GetCourseAnalytics: OK (enrollments=${result.CourseAnalytics.totalEnrollments}, completionRate=${result.CourseAnalytics.completionRate}%)`);
});
// ─── GetBundles ──────────────────────────────────────────────────
it('GetBundles — should list bundles', async () => {
const action = patchCredentials(new GetBundlesAction());
const result = await action.GetBundles({ CompanyID: COMPANY_ID, MaxResults: 5 }, contextUser);
expect(result.Bundles).toBeDefined();
expect(Array.isArray(result.Bundles)).toBe(true);
console.log(` GetBundles: OK (${result.TotalCount} bundles returned)`);
});
// ─── GetUsers ────────────────────────────────────────────────────
it('GetUsers — should list users', async () => {
const action = patchCredentials(new GetLearnWorldsUsersAction());
const result = await action.GetUsers({ CompanyID: COMPANY_ID, MaxResults: 5 }, contextUser);
expect(result.Users).toBeDefined();
expect(Array.isArray(result.Users)).toBe(true);
expect(result.TotalCount).toBeGreaterThan(0);
console.log(` GetUsers: OK (${result.TotalCount} users returned)`);
});
});
// ═══════════════════════════════════════════════════════════════════
// PHASE 2: User lifecycle (create -> update -> tags -> enroll)
// ═══════════════════════════════════════════════════════════════════
describe('Phase 2: User lifecycle', () => {
// ─── FindUserByEmail (non-existent) ──────────────────────────────
it('FindUserByEmail — should return null for non-existent user', async () => {
const action = patchCredentials(new CreateUserAction());
action.SetCompanyContext(COMPANY_ID);
const result = await action.FindUserByEmail('nonexistent-integration-test@example.com', contextUser);
expect(result).toBeNull();
console.log(' FindUserByEmail: OK (no match, API responded)');
});
// ─── CreateUser ──────────────────────────────────────────────────
it('CreateUser — should create a test user', async () => {
const action = patchCredentials(new CreateUserAction());
const result = await action.CreateUser(
{
CompanyID: COMPANY_ID,
Email: TEST_EMAIL,
FirstName: 'MJ',
LastName: 'IntegrationTest',
Role: 'student',
IsActive: true,
SendWelcomeEmail: false,
},
contextUser,
);
expect(result.UserDetails.id).toBeTruthy();
expect(result.UserDetails.email).toBe(TEST_EMAIL);
createdUserId = result.UserDetails.id;
console.log(` CreateUser: OK (userId=${createdUserId}, username=${result.UserDetails.username})`);
});
// ─── UpdateUser ──────────────────────────────────────────────────
it('UpdateUser — should update the created user', async () => {
if (!createdUserId) {
console.warn(' Skipping UpdateUser — no userId');
return;
}
const action = patchCredentials(new UpdateUserAction());
const result = await action.UpdateUser(
{
CompanyID: COMPANY_ID,
UserID: createdUserId,
FirstName: 'MJ-Updated',
LastName: 'IntegrationTest-Updated',
},
contextUser,
);
expect(result.UserDetails).toBeDefined();
expect(result.Summary.fieldsUpdated.length).toBeGreaterThan(0);
console.log(` UpdateUser: OK (fields updated: ${result.Summary.fieldsUpdated.join(', ')})`);
});
// ─── AttachTags ──────────────────────────────────────────────────
it('AttachTags — should attach tags to the user', async () => {
if (!createdUserId) {
console.warn(' Skipping AttachTags — no userId');
return;
}
const action = patchCredentials(new AttachTagsAction());
const result = await action.AttachTags(
{
CompanyID: COMPANY_ID,
UserID: createdUserId,
Tags: ['mj-integration-test', 'automated'],
},
contextUser,
);
expect(result.Success).toBe(true);
console.log(` AttachTags: OK (tags: ${result.Tags.join(', ')})`);
});
// ─── DetachTags ──────────────────────────────────────────────────
it('DetachTags — should detach a tag from the user', async () => {
if (!createdUserId) {
console.warn(' Skipping DetachTags — no userId');
return;
}
const action = patchCredentials(new DetachTagsAction());
const result = await action.DetachTags(
{
CompanyID: COMPANY_ID,
UserID: createdUserId,
Tags: ['automated'],
},
contextUser,
);
expect(result.Success).toBe(true);
console.log(` DetachTags: OK (remaining tags: ${result.Tags.join(', ')})`);
});
// ─── EnrollUser ──────────────────────────────────────────────────
it('EnrollUser — should enroll the created user in a course', async () => {
if (!createdUserId) {
console.warn(' Skipping EnrollUser — no userId');
return;
}
if (!TEST_COURSE_ID) {
console.warn(' Skipping EnrollUser — LW_TEST_COURSE_ID not set');
return;
}
const action = patchCredentials(new EnrollUserAction());
const result = await action.EnrollUser(
{
CompanyID: COMPANY_ID,
UserID: createdUserId,
CourseID: TEST_COURSE_ID,
ProductType: 'course',
Price: 0,
Justification: 'MJ integration test',
NotifyUser: false,
},
contextUser,
);
expect(result.EnrollmentDetails).toBeTruthy();
console.log(` EnrollUser: OK (enrollmentId=${result.EnrollmentDetails.id}, status=${result.EnrollmentDetails.status})`);
});
});
// ═══════════════════════════════════════════════════════════════════
// PHASE 3: User data retrieval (needs created+enrolled user)
// ═══════════════════════════════════════════════════════════════════
describe('Phase 3: User data retrieval', () => {
// ─── GetUserDetails ──────────────────────────────────────────────
it('GetUserDetails — should get full user details', async () => {
if (!createdUserId) {
console.warn(' Skipping GetUserDetails — no userId');
return;
}
const action = patchCredentials(new GetLearnWorldsUserDetailsAction());
const result = await action.GetUserDetails(
{ CompanyID: COMPANY_ID, UserID: createdUserId, IncludeEnrollments: true, IncludeStats: false },
contextUser,
);
expect(result.UserDetails).toBeDefined();
expect(result.UserDetails.id).toBe(createdUserId);
expect(result.UserDetails.email).toBe(TEST_EMAIL);
console.log(` GetUserDetails: OK (email=${result.UserDetails.email}, enrollments=${result.UserDetails.enrollments?.length ?? 0})`);
});
// ─── GetUserEnrollments ──────────────────────────────────────────
it('GetUserEnrollments — should list enrollments', async () => {
if (!createdUserId) {
console.warn(' Skipping GetUserEnrollments — no userId');
return;
}
const action = patchCredentials(new GetUserEnrollmentsAction());
const result = await action.GetUserEnrollments(
{ CompanyID: COMPANY_ID, UserID: createdUserId, IncludeCourseDetails: false },
contextUser,
);
expect(result.Enrollments).toBeDefined();
expect(Array.isArray(result.Enrollments)).toBe(true);
console.log(` GetUserEnrollments: OK (${result.TotalCount} enrollments)`);
});
// ─── GetUserProgress ─────────────────────────────────────────────
it('GetUserProgress — should get user progress', async () => {
if (!createdUserId) {
console.warn(' Skipping GetUserProgress — no userId');
return;
}
const action = patchCredentials(new GetLearnWorldsUserProgressAction());
const result = await action.GetUserProgress(
{
CompanyID: COMPANY_ID,
UserID: createdUserId,
...(TEST_COURSE_ID ? { CourseID: TEST_COURSE_ID } : {}),
},
contextUser,
);
expect(result.UserProgress).toBeDefined();
console.log(` GetUserProgress: OK (courses=${result.UserProgress.totalCourses}, overall=${result.UserProgress.overallProgressPercentage}%)`);
});
// ─── UpdateUserProgress ────────────────────────────────────────────
it('UpdateUserProgress — should update course progress', async () => {
if (!createdUserId || !TEST_COURSE_ID) {
console.warn(' Skipping UpdateUserProgress — no userId or no TEST_COURSE_ID');
return;
}
const action = patchCredentials(new UpdateUserProgressAction());
try {
const result = await action.UpdateProgress(
{
CompanyID: COMPANY_ID,
UserID: createdUserId,
CourseID: TEST_COURSE_ID,
...(TEST_LESSON_ID ? { LessonID: TEST_LESSON_ID, Completed: true } : { ProgressPercentage: 10 }),
},
contextUser,
);
expect(result.ProgressDetails).toBeDefined();
expect(result.Summary).toBeDefined();
const updateType = result.ProgressDetails.updateType;
const newPct = result.Summary.newPercentage;
console.log(` UpdateUserProgress: OK (type=${updateType}, newProgress=${newPct}%, completed=${result.Summary.isCompleted})`);
} catch (error) {
// LearnWorlds enrollment/progress endpoints may not be available for all course types
// or may take time to register a new enrollment. Log and pass if it's a 404.
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('not enrolled') || msg.includes('does not exist') || msg.includes('404')) {
console.warn(` UpdateUserProgress: SKIPPED (enrollment not ready or endpoint unavailable: ${msg})`);
} else {
throw error;
}
}
});
// ─── GetCertificates ────────────────────────────────────────────
it('GetCertificates — should list certificates for the user', async () => {
if (!createdUserId) {
console.warn(' Skipping GetCertificates — no userId');
return;
}
const action = patchCredentials(new GetCertificatesAction());
try {
const result = await action.GetCertificates(
{
CompanyID: COMPANY_ID,
UserID: createdUserId,
IncludeDownloadLinks: true,
MaxResults: 10,
},
contextUser,
);
expect(result.Certificates).toBeDefined();
expect(Array.isArray(result.Certificates)).toBe(true);
console.log(` GetCertificates: OK (${result.TotalCount} certificate(s) found)`);
} catch (error) {
// A newly created test user typically has no certificates.
// The certificates endpoint may return 404 if no certificates exist.
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('does not exist') || msg.includes('404')) {
console.warn(` GetCertificates: SKIPPED (no certificates for test user — endpoint returned 404)`);
} else {
throw error;
}
}
});
// ─── GetQuizResults ─────────────────────────────────────────────
it('GetQuizResults — should list quiz results for the user', async () => {
if (!createdUserId) {
console.warn(' Skipping GetQuizResults — no userId');
return;
}
const action = patchCredentials(new GetQuizResultsAction());
try {
const result = await action.GetQuizResults(
{
CompanyID: COMPANY_ID,
UserID: createdUserId,
IncludeQuestions: false,
IncludeAnswers: false,
MaxResults: 10,
},
contextUser,
);
expect(result.QuizResults).toBeDefined();
expect(Array.isArray(result.QuizResults)).toBe(true);
console.log(` GetQuizResults: OK (${result.TotalCount} quiz result(s) found)`);
} catch (error) {
// A newly created test user has no quiz attempts.
// The quiz-results endpoint may return 404.
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('does not exist') || msg.includes('404')) {
console.warn(` GetQuizResults: SKIPPED (no quiz results for test user — endpoint returned 404)`);
} else {
throw error;
}
}
});
// ─── FindUserByEmail (verify) ────────────────────────────────────
it('FindUserByEmail (verify) — should find the user we created', async () => {
if (!createdUserId) {
console.warn(' Skipping FindUserByEmail verify — no userId');
return;
}
const action = patchCredentials(new CreateUserAction());
action.SetCompanyContext(COMPANY_ID);
const result = await action.FindUserByEmail(TEST_EMAIL, contextUser);
expect(result).not.toBeNull();
expect(result!.email.toLowerCase()).toBe(TEST_EMAIL.toLowerCase());
console.log(` FindUserByEmail (verify): OK (found user id=${result!.id})`);
});
});
// ═══════════════════════════════════════════════════════════════════
// PHASE 4: SSO Login
// ═══════════════════════════════════════════════════════════════════
describe('Phase 4: SSO Login', () => {
it('SSOLogin — should generate an SSO URL', async () => {
const action = patchCredentials(new SSOLoginAction());
const result = await action.GenerateSSOUrl(
{ CompanyID: COMPANY_ID, Email: TEST_EMAIL },
contextUser,
);
expect(result.LoginURL).toBeTruthy();
expect(result.LoginURL).toContain('http');
console.log(` SSOLogin: OK (loginURL=${result.LoginURL})`);
});
});
// ═══════════════════════════════════════════════════════════════════
// PHASE 5: Orchestration — OnboardLearner
// ═══════════════════════════════════════════════════════════════════
describe('Phase 5: OnboardLearner orchestration', () => {
it('OnboardLearner — should create user + enroll + SSO in one call', async () => {
const action = patchCredentials(new OnboardLearnerAction());
// Patch credentials at the prototype level so inner action instances
// (CreateUserAction, EnrollUserAction, SSOLoginAction) also get them
const credentialMock = async () => ({
CompanyID: COMPANY_ID,
APIKey: API_KEY,
ExternalSystemID: SCHOOL_DOMAIN,
AccessToken: null,
CustomAttribute1: null,
});
const apiCredMock = async () => ({
apiKey: API_KEY,
apiSecret: undefined,
accessToken: undefined,
});
vi.spyOn(LearnWorldsBaseAction.prototype as Record<string, unknown>, 'getCompanyIntegration' as never).mockImplementation(credentialMock as never);
vi.spyOn(LearnWorldsBaseAction.prototype as Record<string, unknown>, 'getAPICredentials' as never).mockImplementation(apiCredMock as never);
const result = await action.OnboardLearner(
{
CompanyID: COMPANY_ID,
Email: ONBOARD_EMAIL,
FirstName: 'MJ',
LastName: 'OnboardTest',
Role: 'student',
...(TEST_COURSE_ID ? { CourseIDs: [TEST_COURSE_ID] } : {}),
SendWelcomeEmail: false,
},
contextUser,
);
expect(result.Success).toBe(true);
expect(result.LearnWorldsUserId).toBeTruthy();
expect(result.IsNewUser).toBe(true);
onboardedUserId = result.LearnWorldsUserId;
console.log(` OnboardLearner: OK`);
console.log(` userId = ${result.LearnWorldsUserId}`);
console.log(` isNewUser = ${result.IsNewUser}`);
console.log(` loginURL = ${result.LoginURL}`);
console.log(` enrollments = ${result.Enrollments.length} (${result.Enrollments.filter((e) => e.success).length} succeeded)`);
if (result.Errors.length > 0) {
console.log(` errors = ${result.Errors.join('; ')}`);
}
});
it('OnboardLearner — should find existing user on second call', async () => {
if (!onboardedUserId) {
console.warn(' Skipping OnboardLearner re-run — no onboardedUserId');
return;
}
const action = patchCredentials(new OnboardLearnerAction());
const result = await action.OnboardLearner(
{
CompanyID: COMPANY_ID,
Email: ONBOARD_EMAIL,
FirstName: 'MJ',
LastName: 'OnboardTest',
},
contextUser,
);
expect(result.IsNewUser).toBe(false);
expect(result.LearnWorldsUserId).toBe(onboardedUserId);
console.log(` OnboardLearner (re-run): OK (isNewUser=false, same userId=${result.LearnWorldsUserId})`);
});
});
// ═══════════════════════════════════════════════════════════════════
// PHASE 6: Bulk data retrieval — rate limit stress test
//
// This is the test that reproduces GitHub issue #2312.
// It fetches ALL users then hits the enrollment endpoint for each one,
// which is the exact pattern that triggers 429 rate limiting.
// ═══════════════════════════════════════════════════════════════════
describe('Phase 6: GetBulkData rate limit stress test', () => {
it('GetBulkData — should fetch all users + enrollments without 429 failures', async () => {
// Reset global rate limiter to ensure a clean sliding window
LearnWorldsBaseAction.ResetRateLimiter();
// Seed the CLIENT_ID env var so buildRequestConfig resolves it
// (normally seeded by patchCredentials() in earlier phases)
const provider = 'LEARNWORLDS';
process.env[`BIZAPPS_${provider}_${COMPANY_ID}_CLIENT_ID`] = CLIENT_ID || SCHOOL_DOMAIN;
// Patch credentials at the prototype level so all inner action instances
// (GetUsersAction, GetUserEnrollmentsAction, etc.) also get them
const credentialMock = async () => ({
CompanyID: COMPANY_ID,
APIKey: API_KEY,
ExternalSystemID: SCHOOL_DOMAIN,
AccessToken: null,
CustomAttribute1: null,
});
const apiCredMock = async () => ({
apiKey: API_KEY,
apiSecret: undefined,
accessToken: undefined,
});
vi.spyOn(LearnWorldsBaseAction.prototype as Record<string, unknown>, 'getCompanyIntegration' as never).mockImplementation(credentialMock as never);
vi.spyOn(LearnWorldsBaseAction.prototype as Record<string, unknown>, 'getAPICredentials' as never).mockImplementation(apiCredMock as never);
// Track 429 retries via console.warn spy
const retryWarnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
const msg = String(args[0]);
if (msg.includes('429 rate limited')) {
retryWarnings.push(msg);
}
originalWarn.apply(console, args);
};
const action = new GetLearnWorldsBulkDataAction();
const startTime = Date.now();
try {
const result = await action.GetBulkData(
{
CompanyID: COMPANY_ID,
IncludeUsers: true,
IncludeCourses: true,
IncludeBundles: true,
IncludeEnrollments: true,
IncludeProgress: true,
IncludeCertificates: false,
IncludeQuizResults: false,
MaxResultsPerEntity: 20,
},
contextUser,
);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
// Log comprehensive results
console.log(`\n ══════════════════════════════════════════════════`);
console.log(` GetBulkData Rate Limit Stress Test Results`);
console.log(` ──────────────────────────────────────────────────`);
console.log(` Duration: ${elapsed}s`);
console.log(` Total API calls: ${result.totalApiCalls}`);
console.log(` Users: ${result.users?.length ?? 0}`);
console.log(` Courses: ${result.courses?.length ?? 0}`);
console.log(` Bundles: ${result.bundles?.length ?? 0}`);
console.log(` Enrollments: ${result.enrollments?.length ?? 0}`);
console.log(` Progress: ${result.progress?.length ?? 0}`);
console.log(` 429 retries: ${retryWarnings.length}`);
console.log(` Errors: ${result.errors.length}`);
if (retryWarnings.length > 0) {
console.log(` ── Retry details ──`);
for (const warning of retryWarnings) {
console.log(` ${warning}`);
}
}
if (result.errors.length > 0) {
console.log(` ── Errors ──`);
for (const err of result.errors) {
console.log(` [${err.entity}] ${err.entityId || '(no id)'}: ${err.message}`);
}
}
console.log(` ══════════════════════════════════════════════════\n`);
// The key assertion: zero errors means all enrollment/progress fetches succeeded.
// Before the fix, ~35 out of 100+ users would fail with 429 errors.
expect(result.errors).toEqual([]);
expect(result.users).toBeDefined();
expect(result.users!.length).toBeGreaterThan(0);
expect(result.enrollments).toBeDefined();
expect(result.progress).toBeDefined();
} finally {
console.warn = originalWarn;
}
}, 300_000); // 5-minute timeout — rate limiting (25 req/10s + inter-batch delays) makes bulk runs slower
});
});