@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
1,154 lines (904 loc) • 52.4 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 { GetLearnWorldsCoursesAction } from '../providers/learnworlds/actions/get-courses.action';
import { ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import { LearnWorldsCourse, CourseCatalogSummary } 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 object matching the LWApiCourse interface.
* Provides sensible defaults that can be selectively overridden per test.
*/
function createRawApiCourse(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'course-1',
title: 'Intro to TypeScript',
subtitle: 'Learn TypeScript from scratch',
description: 'A comprehensive course on TypeScript.',
short_description: 'TS basics',
status: 'published',
visibility: 'public',
is_active: true,
is_free: false,
price: 49.99,
currency: 'USD',
original_price: 79.99,
category_id: 'cat-1',
category_name: 'Programming',
tags: ['typescript', 'programming'],
level: 'beginner',
language: 'en',
duration: 3600,
estimated_duration: 7200,
thumbnail_url: 'https://example.com/thumb.jpg',
cover_image_url: 'https://example.com/cover.jpg',
promo_video_url: 'https://example.com/promo.mp4',
instructor_id: 'inst-1',
instructor_name: 'Jane Doe',
instructor_bio: 'Senior developer',
instructor_avatar: 'https://example.com/avatar.jpg',
total_units: 10,
total_lessons: 25,
total_quizzes: 5,
total_assignments: 3,
total_enrollments: 150,
active_enrollments: 80,
completion_rate: 72,
average_rating: 4.5,
total_reviews: 42,
created_at: '2024-06-15T10:00:00Z',
updated_at: '2024-06-20T12:00:00Z',
published_at: '2024-06-16T08:00:00Z',
requires_approval: false,
has_prerequisites: true,
prerequisites: ['course-0'],
certificate_available: true,
objectives: ['Learn TS basics', 'Build projects'],
target_audience: ['Beginners', 'JS developers'],
requirements: ['Basic JS knowledge'],
...overrides,
};
}
describe('GetLearnWorldsCoursesAction', () => {
let action: GetLearnWorldsCoursesAction;
let contextUser: UserInfo;
beforeEach(() => {
action = new GetLearnWorldsCoursesAction();
contextUser = createMockContextUser();
});
// ─── GetCourses() typed public method ──────────────────────────────
describe('GetCourses() typed method', () => {
it('should return mapped courses with summary on happy path', async () => {
const rawCourse = createRawApiCourse();
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.TotalCount).toBe(1);
expect(result.Courses).toHaveLength(1);
expect(result.Summary).toBeDefined();
expect(result.Summary.totalCourses).toBe(1);
const course: LearnWorldsCourse = result.Courses[0];
expect(course.id).toBe('course-1');
expect(course.title).toBe('Intro to TypeScript');
expect(course.subtitle).toBe('Learn TypeScript from scratch');
expect(course.description).toBe('A comprehensive course on TypeScript.');
expect(course.shortDescription).toBe('TS basics');
expect(course.status).toBe('published');
expect(course.visibility).toBe('public');
expect(course.isActive).toBe(true);
expect(course.isFree).toBe(false);
expect(course.price).toBe(49.99);
expect(course.currency).toBe('USD');
expect(course.originalPrice).toBe(79.99);
expect(course.discountPercentage).toBe(38); // Math.round(((79.99 - 49.99) / 79.99) * 100)
expect(course.categoryId).toBe('cat-1');
expect(course.categoryName).toBe('Programming');
expect(course.tags).toEqual(['typescript', 'programming']);
expect(course.level).toBe('beginner');
expect(course.language).toBe('en');
expect(course.duration).toBe(3600);
expect(course.thumbnailUrl).toBe('https://example.com/thumb.jpg');
expect(course.coverImageUrl).toBe('https://example.com/cover.jpg');
expect(course.promoVideoUrl).toBe('https://example.com/promo.mp4');
expect(course.instructorId).toBe('inst-1');
expect(course.instructorName).toBe('Jane Doe');
expect(course.instructorBio).toBe('Senior developer');
expect(course.instructorAvatarUrl).toBe('https://example.com/avatar.jpg');
expect(course.totalUnits).toBe(10);
expect(course.totalLessons).toBe(25);
expect(course.totalQuizzes).toBe(5);
expect(course.totalAssignments).toBe(3);
expect(course.totalEnrollments).toBe(150);
expect(course.activeEnrollments).toBe(80);
expect(course.completionRate).toBe(72);
expect(course.averageRating).toBe(4.5);
expect(course.totalReviews).toBe(42);
expect(course.requiresApproval).toBe(false);
expect(course.hasPrerequisites).toBe(true);
expect(course.prerequisites).toEqual(['course-0']);
expect(course.certificateAvailable).toBe(true);
expect(course.objectives).toEqual(['Learn TS basics', 'Build projects']);
expect(course.targetAudience).toEqual(['Beginners', 'JS developers']);
expect(course.requirements).toEqual(['Basic JS knowledge']);
});
it('should handle empty course list', async () => {
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.TotalCount).toBe(0);
expect(result.Courses).toEqual([]);
expect(result.Summary.totalCourses).toBe(0);
expect(result.Summary.publishedCourses).toBe(0);
expect(result.Summary.draftCourses).toBe(0);
expect(result.Summary.freeCourses).toBe(0);
expect(result.Summary.paidCourses).toBe(0);
});
});
// ─── Course mapping: identity ──────────────────────────────────────
describe('mapCourseIdentity', () => {
it('should use _id fallback when id is undefined', async () => {
const rawCourse = createRawApiCourse({ id: undefined, _id: 'alt-course-id' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].id).toBe('alt-course-id');
});
it('should default id to empty string when both id and _id are missing', async () => {
const rawCourse = createRawApiCourse({ id: undefined, _id: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].id).toBe('');
});
it('should default isActive to true when is_active is not explicitly false', async () => {
const rawCourse = createRawApiCourse({ is_active: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].isActive).toBe(true);
});
it('should set isActive to false when is_active is explicitly false', async () => {
const rawCourse = createRawApiCourse({ is_active: false });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].isActive).toBe(false);
});
it('should mark course as free when is_free is true', async () => {
const rawCourse = createRawApiCourse({ is_free: true, price: 0 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].isFree).toBe(true);
});
it('should mark course as free when price is 0', async () => {
const rawCourse = createRawApiCourse({ is_free: false, price: 0 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].isFree).toBe(true);
});
it('should use excerpt as shortDescription fallback', async () => {
const rawCourse = createRawApiCourse({ short_description: undefined, excerpt: 'Excerpt text' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].shortDescription).toBe('Excerpt text');
});
it('should use category.id and category.name as fallbacks', async () => {
const rawCourse = createRawApiCourse({
category_id: undefined,
category_name: undefined,
category: { id: 'cat-nested', name: 'Nested Category' },
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].categoryId).toBe('cat-nested');
expect(result.Courses[0].categoryName).toBe('Nested Category');
});
it('should map status aliases correctly', async () => {
const tests: Array<{ input: string; expected: string }> = [
{ input: 'published', expected: 'published' },
{ input: 'active', expected: 'published' },
{ input: 'draft', expected: 'draft' },
{ input: 'unpublished', expected: 'draft' },
{ input: 'coming_soon', expected: 'coming_soon' },
{ input: 'upcoming', expected: 'coming_soon' },
{ input: 'unknown_status', expected: 'draft' },
];
for (const { input, expected } of tests) {
const rawCourse = createRawApiCourse({ status: input });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].status).toBe(expected);
}
});
it('should map visibility aliases correctly', async () => {
const tests: Array<{ input: string; expected: string }> = [
{ input: 'public', expected: 'public' },
{ input: 'open', expected: 'public' },
{ input: 'private', expected: 'private' },
{ input: 'closed', expected: 'private' },
{ input: 'hidden', expected: 'hidden' },
{ input: 'unlisted', expected: 'hidden' },
{ input: 'unknown_vis', expected: 'public' },
];
for (const { input, expected } of tests) {
const rawCourse = createRawApiCourse({ visibility: input });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].visibility).toBe(expected);
}
});
it('should use access_type as fallback for visibility', async () => {
const rawCourse = createRawApiCourse({ visibility: undefined, access_type: 'closed' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].visibility).toBe('private');
});
it('should map level aliases correctly', async () => {
const tests: Array<{ input: string; expected: string }> = [
{ input: 'beginner', expected: 'beginner' },
{ input: 'basic', expected: 'beginner' },
{ input: 'introductory', expected: 'beginner' },
{ input: 'intermediate', expected: 'intermediate' },
{ input: 'medium', expected: 'intermediate' },
{ input: 'advanced', expected: 'advanced' },
{ input: 'expert', expected: 'advanced' },
{ input: 'all', expected: 'all' },
{ input: 'any', expected: 'all' },
{ input: 'mixed', expected: 'all' },
{ input: 'unknown_level', expected: 'all' },
];
for (const { input, expected } of tests) {
const rawCourse = createRawApiCourse({ level: input });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].level).toBe(expected);
}
});
it('should use difficulty as fallback for level', async () => {
const rawCourse = createRawApiCourse({ level: undefined, difficulty: 'expert' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].level).toBe('advanced');
});
it('should default language to en when missing', async () => {
const rawCourse = createRawApiCourse({ language: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].language).toBe('en');
});
it('should use image as fallback for thumbnailUrl', async () => {
const rawCourse = createRawApiCourse({ thumbnail_url: undefined, image: 'https://example.com/image.jpg' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].thumbnailUrl).toBe('https://example.com/image.jpg');
});
it('should use cover_image as fallback for coverImageUrl', async () => {
const rawCourse = createRawApiCourse({ cover_image_url: undefined, cover_image: 'https://example.com/cover2.jpg' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].coverImageUrl).toBe('https://example.com/cover2.jpg');
});
it('should use video_url as fallback for promoVideoUrl', async () => {
const rawCourse = createRawApiCourse({ promo_video_url: undefined, video_url: 'https://example.com/vid.mp4' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].promoVideoUrl).toBe('https://example.com/vid.mp4');
});
it('should use estimated_duration as fallback for duration', async () => {
const rawCourse = createRawApiCourse({ duration: undefined, estimated_duration: 5400 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].duration).toBe(5400);
});
});
// ─── Course mapping: pricing ───────────────────────────────────────
describe('mapCoursePricing', () => {
it('should default currency to USD when missing', async () => {
const rawCourse = createRawApiCourse({ currency: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].currency).toBe('USD');
});
});
// ─── Discount calculation ─────────────────────────────────────────
describe('discount calculation', () => {
it('should return undefined when original_price is not set', async () => {
const rawCourse = createRawApiCourse({ original_price: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].discountPercentage).toBeUndefined();
});
it('should return undefined when original_price equals current price', async () => {
const rawCourse = createRawApiCourse({ original_price: 49.99, price: 49.99 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].discountPercentage).toBeUndefined();
});
it('should return undefined when original_price is less than current price', async () => {
const rawCourse = createRawApiCourse({ original_price: 30, price: 49.99 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].discountPercentage).toBeUndefined();
});
it('should calculate correct discount percentage', async () => {
const rawCourse = createRawApiCourse({ original_price: 100, price: 75 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].discountPercentage).toBe(25);
});
it('should round discount percentage to nearest integer', async () => {
const rawCourse = createRawApiCourse({ original_price: 99.99, price: 66.66 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
// Math.round(((99.99 - 66.66) / 99.99) * 100) = Math.round(33.33...) = 33
expect(result.Courses[0].discountPercentage).toBe(33);
});
it('should return undefined when current price is 0 (falsy)', async () => {
const rawCourse = createRawApiCourse({ original_price: 100, price: 0 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
// !currentPrice evaluates to true for 0, so undefined
expect(result.Courses[0].discountPercentage).toBeUndefined();
});
});
// ─── Course mapping: instructor ───────────────────────────────────
describe('mapCourseInstructor', () => {
it('should use nested instructor object as fallback', async () => {
const rawCourse = createRawApiCourse({
instructor_id: undefined,
instructor_name: undefined,
instructor_bio: undefined,
instructor_avatar: undefined,
instructor: { id: 'nested-inst', name: 'Nested Instructor', bio: 'Nested bio', avatar_url: 'https://example.com/nested.jpg' },
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].instructorId).toBe('nested-inst');
expect(result.Courses[0].instructorName).toBe('Nested Instructor');
expect(result.Courses[0].instructorBio).toBe('Nested bio');
expect(result.Courses[0].instructorAvatarUrl).toBe('https://example.com/nested.jpg');
});
it('should use author_id and author_name as last-resort fallbacks', async () => {
const rawCourse = createRawApiCourse({
instructor_id: undefined,
instructor_name: undefined,
instructor: undefined,
author_id: 'author-1',
author_name: 'Author Name',
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].instructorId).toBe('author-1');
expect(result.Courses[0].instructorName).toBe('Author Name');
});
});
// ─── Course mapping: content stats ────────────────────────────────
describe('mapCourseContentStats', () => {
it('should use sections_count and lessons_count as fallbacks', async () => {
const rawCourse = createRawApiCourse({
total_units: undefined,
total_lessons: undefined,
total_quizzes: undefined,
total_assignments: undefined,
sections_count: 8,
lessons_count: 20,
quizzes_count: 4,
assignments_count: 2,
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].totalUnits).toBe(8);
expect(result.Courses[0].totalLessons).toBe(20);
expect(result.Courses[0].totalQuizzes).toBe(4);
expect(result.Courses[0].totalAssignments).toBe(2);
});
it('should default content stats to 0 when all are missing', async () => {
const rawCourse = createRawApiCourse({
total_units: undefined,
sections_count: undefined,
total_lessons: undefined,
lessons_count: undefined,
total_quizzes: undefined,
quizzes_count: undefined,
total_assignments: undefined,
assignments_count: undefined,
total_enrollments: undefined,
students_count: undefined,
active_enrollments: undefined,
active_students: undefined,
completion_rate: undefined,
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].totalUnits).toBe(0);
expect(result.Courses[0].totalLessons).toBe(0);
expect(result.Courses[0].totalQuizzes).toBe(0);
expect(result.Courses[0].totalAssignments).toBe(0);
expect(result.Courses[0].totalEnrollments).toBe(0);
expect(result.Courses[0].activeEnrollments).toBe(0);
expect(result.Courses[0].completionRate).toBe(0);
});
it('should use students_count as fallback for totalEnrollments', async () => {
const rawCourse = createRawApiCourse({ total_enrollments: undefined, students_count: 99 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].totalEnrollments).toBe(99);
});
it('should use active_students as fallback for activeEnrollments', async () => {
const rawCourse = createRawApiCourse({ active_enrollments: undefined, active_students: 33 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].activeEnrollments).toBe(33);
});
it('should use rating as fallback for averageRating', async () => {
const rawCourse = createRawApiCourse({ average_rating: undefined, rating: 3.8 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].averageRating).toBe(3.8);
});
it('should use reviews_count as fallback for totalReviews', async () => {
const rawCourse = createRawApiCourse({ total_reviews: undefined, reviews_count: 17 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].totalReviews).toBe(17);
});
it('should use total_duration as fallback for estimatedDuration', async () => {
const rawCourse = createRawApiCourse({ estimated_duration: undefined, total_duration: 4500 });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].estimatedDuration).toBe(4500);
});
});
// ─── Course mapping: meta ─────────────────────────────────────────
describe('mapCourseMeta', () => {
it('should use created/updated fallbacks', async () => {
const rawCourse = createRawApiCourse({
created_at: undefined,
created: '2023-01-01T00:00:00Z',
updated_at: undefined,
updated: '2023-02-01T00:00:00Z',
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].createdAt).toEqual(new Date('2023-01-01T00:00:00Z'));
expect(result.Courses[0].updatedAt).toEqual(new Date('2023-02-01T00:00:00Z'));
});
it('should parse epoch timestamps (seconds) for date fields', async () => {
const epochSeconds = 1700000000; // ~2023-11-14
const rawCourse = createRawApiCourse({
created_at: undefined,
created: epochSeconds,
updated_at: undefined,
updated: epochSeconds,
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].createdAt).toEqual(new Date(epochSeconds * 1000));
expect(result.Courses[0].updatedAt).toEqual(new Date(epochSeconds * 1000));
});
it('should set publishedAt to undefined when not provided', async () => {
const rawCourse = createRawApiCourse({ published_at: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].publishedAt).toBeUndefined();
});
it('should set enrollment dates to undefined when not provided', async () => {
const rawCourse = createRawApiCourse({ enrollment_start: undefined, enrollment_end: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].enrollmentStartDate).toBeUndefined();
expect(result.Courses[0].enrollmentEndDate).toBeUndefined();
});
it('should default boolean meta fields to false when missing', async () => {
const rawCourse = createRawApiCourse({
requires_approval: undefined,
has_prerequisites: undefined,
certificate_available: undefined,
has_certificate: undefined,
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].requiresApproval).toBe(false);
expect(result.Courses[0].hasPrerequisites).toBe(false);
expect(result.Courses[0].certificateAvailable).toBe(false);
});
it('should use has_certificate as fallback for certificateAvailable', async () => {
const rawCourse = createRawApiCourse({ certificate_available: undefined, has_certificate: true });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].certificateAvailable).toBe(true);
});
it('should use learning_objectives as fallback for objectives', async () => {
const rawCourse = createRawApiCourse({ objectives: undefined, learning_objectives: ['Objective A'] });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].objectives).toEqual(['Objective A']);
});
it('should use prerequisites_text as fallback for requirements', async () => {
const rawCourse = createRawApiCourse({ requirements: undefined, prerequisites_text: ['Prereq text'] });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].requirements).toEqual(['Prereq text']);
});
it('should default array meta fields to empty arrays when missing', async () => {
const rawCourse = createRawApiCourse({
prerequisites: undefined,
objectives: undefined,
learning_objectives: undefined,
target_audience: undefined,
requirements: undefined,
prerequisites_text: undefined,
});
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawCourse] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Courses[0].prerequisites).toEqual([]);
expect(result.Courses[0].objectives).toEqual([]);
expect(result.Courses[0].targetAudience).toEqual([]);
expect(result.Courses[0].requirements).toEqual([]);
});
});
// ─── Catalog summary: category, level, language counts ────────────
describe('catalog summary counts', () => {
it('should count categories, levels, and languages correctly', async () => {
const courses = [
createRawApiCourse({ id: 'c1', category_name: 'Programming', level: 'beginner', language: 'en' }),
createRawApiCourse({ id: 'c2', category_name: 'Programming', level: 'intermediate', language: 'en' }),
createRawApiCourse({ id: 'c3', category_name: 'Design', level: 'beginner', language: 'es' }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const summary: CourseCatalogSummary = result.Summary;
expect(summary.categoryCounts).toEqual({ Programming: 2, Design: 1 });
expect(summary.levelCounts).toEqual({ beginner: 2, intermediate: 1 });
expect(summary.languageCounts).toEqual({ en: 2, es: 1 });
});
it('should count published vs draft vs free vs paid correctly', async () => {
const courses = [
createRawApiCourse({ id: 'c1', status: 'published', is_free: true, price: 0 }),
createRawApiCourse({ id: 'c2', status: 'published', is_free: false, price: 50 }),
createRawApiCourse({ id: 'c3', status: 'draft', is_free: false, price: 30 }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const summary = result.Summary;
expect(summary.totalCourses).toBe(3);
expect(summary.publishedCourses).toBe(2);
expect(summary.draftCourses).toBe(1);
expect(summary.freeCourses).toBe(1);
expect(summary.paidCourses).toBe(2);
});
it('should skip missing category names in categoryCounts', async () => {
const courses = [
createRawApiCourse({ id: 'c1', category_name: undefined }),
createRawApiCourse({ id: 'c2', category_name: 'Design' }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
// Only 'Design' should appear; the undefined category should be skipped
expect(result.Summary.categoryCounts).toEqual({ Design: 1 });
});
});
// ─── Catalog summary: enrollment stats ────────────────────────────
describe('catalog summary enrollment stats', () => {
it('should calculate totalEnrollments and averagePerCourse', async () => {
const courses = [
createRawApiCourse({ id: 'c1', total_enrollments: 100 }),
createRawApiCourse({ id: 'c2', total_enrollments: 50 }),
createRawApiCourse({ id: 'c3', total_enrollments: 150 }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const stats = result.Summary.enrollmentStats;
expect(stats.totalEnrollments).toBe(300);
expect(stats.averageEnrollmentsPerCourse).toBe(100); // Math.round(300 / 3)
});
it('should identify most popular courses (top 5, sorted by enrollments desc)', async () => {
const courses = Array.from({ length: 7 }, (_, i) =>
createRawApiCourse({
id: `c${i}`,
title: `Course ${i}`,
total_enrollments: (i + 1) * 10,
status: 'published',
}),
);
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const popular = result.Summary.enrollmentStats.mostPopularCourses;
expect(popular).toHaveLength(5);
// Most popular first
expect(popular[0].id).toBe('c6');
expect(popular[0].enrollments).toBe(70);
expect(popular[4].id).toBe('c2');
expect(popular[4].enrollments).toBe(30);
});
it('should exclude courses with 0 enrollments from most popular', async () => {
const courses = [
createRawApiCourse({ id: 'c1', title: 'Course 1', total_enrollments: 0 }),
createRawApiCourse({ id: 'c2', title: 'Course 2', total_enrollments: 10 }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const popular = result.Summary.enrollmentStats.mostPopularCourses;
expect(popular).toHaveLength(1);
expect(popular[0].id).toBe('c2');
});
it('should handle empty enrollment stats for empty course list', async () => {
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const stats = result.Summary.enrollmentStats;
expect(stats.totalEnrollments).toBe(0);
expect(stats.averageEnrollmentsPerCourse).toBe(0);
expect(stats.mostPopularCourses).toEqual([]);
});
});
// ─── Catalog summary: price stats ─────────────────────────────────
describe('catalog summary price stats', () => {
it('should calculate average, min, max for paid courses', async () => {
const courses = [
createRawApiCourse({ id: 'c1', is_free: false, price: 20 }),
createRawApiCourse({ id: 'c2', is_free: false, price: 60 }),
createRawApiCourse({ id: 'c3', is_free: false, price: 100 }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const ps = result.Summary.priceStats;
expect(ps.averagePrice).toBe(60); // Math.round((20+60+100)/3) = 60
expect(ps.minPrice).toBe(20);
expect(ps.maxPrice).toBe(100);
expect(ps.currency).toBe('USD');
});
it('should exclude free courses from price stats', async () => {
const courses = [
createRawApiCourse({ id: 'c1', is_free: true, price: 0 }),
createRawApiCourse({ id: 'c2', is_free: false, price: 50 }),
createRawApiCourse({ id: 'c3', is_free: false, price: 100 }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const ps = result.Summary.priceStats;
expect(ps.averagePrice).toBe(75); // Math.round((50+100)/2) = 75
expect(ps.minPrice).toBe(50);
expect(ps.maxPrice).toBe(100);
});
it('should return zeroed price stats when all courses are free', async () => {
const courses = [
createRawApiCourse({ id: 'c1', is_free: true, price: 0 }),
createRawApiCourse({ id: 'c2', is_free: true, price: 0 }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const ps = result.Summary.priceStats;
// No paid courses means the default summary priceStats stays
expect(ps.averagePrice).toBe(0);
expect(ps.minPrice).toBe(0);
expect(ps.maxPrice).toBe(0);
expect(ps.currency).toBe('USD');
});
it('should use first paid course currency for priceStats', async () => {
const courses = [
createRawApiCourse({ id: 'c1', is_free: false, price: 50, currency: 'EUR' }),
createRawApiCourse({ id: 'c2', is_free: false, price: 75, currency: 'GBP' }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(courses as never);
const result = await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.priceStats.currency).toBe('EUR');
});
});
// ─── Query param building ─────────────────────────────────────────
describe('query param building', () => {
it('should pass search text as query param', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', SearchText: 'typescript' }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.search).toBe('typescript');
});
it('should pass status filter', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', Status: 'published' }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.status).toBe('published');
});
it('should pass category_id, level, language filters', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses(
{ CompanyID: 'comp-1', CategoryID: 'cat-1', Level: 'beginner', Language: 'es' },
contextUser,
);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.category_id).toBe('cat-1');
expect(queryParams.level).toBe('beginner');
expect(queryParams.language).toBe('es');
});
it('should set is_free when OnlyFree is true', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', OnlyFree: true }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.is_free).toBe(true);
});
it('should not set is_free when OnlyFree is false', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', OnlyFree: false }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.is_free).toBeUndefined();
});
it('should pass price range filters when positive', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', MinPrice: 10, MaxPrice: 200 }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.min_price).toBe(10);
expect(queryParams.max_price).toBe(200);
});
it('should not pass price filters when 0', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', MinPrice: 0, MaxPrice: 0 }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.min_price).toBeUndefined();
expect(queryParams.max_price).toBeUndefined();
});
it('should pass tags and instructor_id filters', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', Tags: 'typescript,advanced', InstructorID: 'inst-1' }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.tags).toBe('typescript,advanced');
expect(queryParams.instructor_id).toBe('inst-1');
});
it('should format date filters as ISO 8601', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses(
{ CompanyID: 'comp-1', CreatedAfter: '2024-01-01', CreatedBefore: '2024-12-31' },
contextUser,
);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
// formatLearnWorldsDate returns ISO string
expect(queryParams.created_after).toBe(new Date('2024-01-01').toISOString());
expect(queryParams.created_before).toBe(new Date('2024-12-31').toISOString());
});
it('should build sort param with desc prefix by default', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.sort).toBe('-created');
});
it('should build sort param without prefix for asc order', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', SortBy: 'title', SortOrder: 'asc' }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.sort).toBe('title');
});
it('should build sort param with custom field and desc order', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', SortBy: 'price', SortOrder: 'desc' }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.sort).toBe('-price');
});
it('should include enrollment_stats by default', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.include).toBe('enrollment_stats');
});
it('should omit enrollment_stats include when IncludeEnrollmentStats is false', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', IncludeEnrollmentStats: false }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.include).toBeUndefined();
});
it('should cap limit at 100 even when MaxResults is higher', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', MaxResults: 500 }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.limit).toBe(100);
});
it('should use MaxResults as limit when <= 100', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1', MaxResults: 25 }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.limit).toBe(25);
});
it('should not include undefined optional params in query', async () => {
const spy = vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
await action.GetCourses({ CompanyID: 'comp-1' }, contextUser);
const queryParams = spy.mock.calls[0][1] as Record<string, string | number | boolean>;
expect(queryParams.search).toBeUndefined();
expect(queryParams.status).toBeUndefined();
expect(queryParams.category_id).toBeUndefined();
expect(queryParams.level).toBeUndefined();
expect(queryParams.language).toBeUndefined();
expect(queryParams.is_free).toBeUndefined();
expect(queryParams.min_price).toBeUndefined();
expect(queryParams.max_price).toBeUndefined();
expect(queryParams.tags).toBeUndefined();
expect(queryParams.instructor_id).toBeUndefined();
expect(queryParams.created_after).toBeUndefined();
expect(queryParams.created_before).toBeUndefined();
});
});
// ─── InternalRunAction() ──────────────────────────────────────────
describe('InternalRunAction()', () => {
it('should return success when GetCourses succeeds', async () => {
const mockCourses: LearnWorldsCourse[] = [
{
id: 'c1',
title: 'Course A',
status: 'published',
visibility: 'public',
isActive: true,
isFree: false,
price: 50,
currency: 'USD',
totalUnits: 5,
totalLessons: 10,
totalQuizzes: 2,
totalAssignments: 1,
totalEnrollments: 100,
activeEnrollments: 50,
completionRate: 60,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
requiresApproval: false,
hasPrerequisites: false,
certificateAvailable: true,
},
];
const mockSummary: CourseCatalogSummary = {
totalCourses: 1,
publishedCourses: 1,
draftCourses: 0,
freeCourses: 0,
paidCourses: 1,
categoryCounts: {},
levelCounts: {},
languageCounts: {},
enrollmentStats: {
totalEnrollments: 100,
averageEnrollmentsPerCourse: 100,
mostPopularCourses: [{ id: 'c1', title: 'Course A', enrollments: 100 }],
},
priceStats: { averagePrice: 50, minPrice: 50, maxPrice: 50, currency: 'USD' },
};
vi.spyOn(action, 'GetCourses').mockResolvedValue({
Courses: mockCourses,
TotalCount: 1,
Summary: mockSummary,
});
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'SearchText', Type: 'Input', Value: undefined },
{ Name: 'MaxResults', Type: 'Input', Value: undefined },
],
ContextUser: contextUser,
} as unknown as RunActionParams;
const result = (await ac