@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
465 lines (380 loc) • 18.5 kB
text/typescript
import { describe, it, expect, vi, beforeEach, afterEach } 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 { GetLearnWorldsUsersAction } from '../providers/learnworlds/actions/get-users.action';
import { ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import { LearnWorldsUser, LWApiUser } 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 object matching the LWApiUser interface
*/
function createRawApiUser(overrides: Partial<LWApiUser> = {}): LWApiUser {
return {
id: 'user-1',
email: 'alice@example.com',
username: 'alice',
first_name: 'Alice',
last_name: 'Smith',
full_name: 'Alice Smith',
status: 'active',
role: 'student',
created: '2024-06-15T10:00:00Z',
last_login: '2024-07-01T08:30:00Z',
tags: ['beginner'],
custom_fields: { company: 'Acme' },
course_stats: {
total: 5,
completed: 3,
in_progress: 2,
total_time_spent: 7200,
},
avatar_url: 'https://example.com/avatar.jpg',
bio: 'A learner',
location: 'NYC',
timezone: 'America/New_York',
...overrides,
};
}
describe('GetLearnWorldsUsersAction', () => {
let action: GetLearnWorldsUsersAction;
let contextUser: UserInfo;
beforeEach(() => {
action = new GetLearnWorldsUsersAction();
contextUser = createMockContextUser();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('GetUsers() typed method', () => {
it('should return mapped users and summary on happy path', async () => {
const rawUser = createRawApiUser();
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawUser] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.TotalCount).toBe(1);
expect(result.Users).toHaveLength(1);
expect(result.Summary).toBeDefined();
expect(result.Summary.totalUsers).toBe(1);
});
it('should correctly map all user fields including course_stats', async () => {
const rawUser = createRawApiUser();
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawUser] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
const user: LearnWorldsUser = result.Users[0];
expect(user.id).toBe('user-1');
expect(user.email).toBe('alice@example.com');
expect(user.username).toBe('alice');
expect(user.firstName).toBe('Alice');
expect(user.lastName).toBe('Smith');
expect(user.fullName).toBe('Alice Smith');
expect(user.status).toBe('active');
expect(user.role).toBe('student');
expect(user.createdAt).toBeInstanceOf(Date);
expect(user.lastLoginAt).toBeInstanceOf(Date);
expect(user.tags).toEqual(['beginner']);
expect(user.customFields).toEqual({ company: 'Acme' });
expect(user.totalCourses).toBe(5);
expect(user.completedCourses).toBe(3);
expect(user.inProgressCourses).toBe(2);
expect(user.totalTimeSpent).toBe(7200);
expect(user.avatarUrl).toBe('https://example.com/avatar.jpg');
expect(user.bio).toBe('A learner');
expect(user.location).toBe('NYC');
expect(user.timezone).toBe('America/New_York');
});
it('should use _id fallback when id is missing', async () => {
const rawUser = createRawApiUser({ id: undefined, _id: 'alt-user-id' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawUser] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Users[0].id).toBe('alt-user-id');
});
it('should default missing course_stats to zero', async () => {
const rawUser = createRawApiUser({ course_stats: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawUser] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
const user = result.Users[0];
expect(user.totalCourses).toBe(0);
expect(user.completedCourses).toBe(0);
expect(user.inProgressCourses).toBe(0);
expect(user.totalTimeSpent).toBe(0);
});
it('should compute summary role counts correctly', async () => {
const users: LWApiUser[] = [
createRawApiUser({ id: 'u1', email: 'a@test.com', role: 'student' }),
createRawApiUser({ id: 'u2', email: 'b@test.com', role: 'student' }),
createRawApiUser({ id: 'u3', email: 'c@test.com', role: 'instructor' }),
createRawApiUser({ id: 'u4', email: 'd@test.com', role: 'admin' }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(users as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.usersByRole).toEqual({
student: 2,
instructor: 1,
admin: 1,
});
});
it('should compute summary active/inactive/suspended counts', async () => {
const users: LWApiUser[] = [
createRawApiUser({ id: 'u1', email: 'a@test.com', status: 'active' }),
createRawApiUser({ id: 'u2', email: 'b@test.com', status: 'active' }),
createRawApiUser({ id: 'u3', email: 'c@test.com', status: 'inactive' }),
createRawApiUser({ id: 'u4', email: 'd@test.com', status: 'suspended' }),
createRawApiUser({ id: 'u5', email: 'e@test.com', status: 'blocked' }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(users as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.activeUsers).toBe(2);
expect(result.Summary.inactiveUsers).toBe(1);
// 'blocked' maps to 'suspended' via mapUserStatus
expect(result.Summary.suspendedUsers).toBe(2);
});
it('should compute averageCoursesPerUser correctly', async () => {
const users: LWApiUser[] = [
createRawApiUser({ id: 'u1', email: 'a@test.com', course_stats: { total: 10 } }),
createRawApiUser({ id: 'u2', email: 'b@test.com', course_stats: { total: 6 } }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(users as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.averageCoursesPerUser).toBe(8); // (10 + 6) / 2
});
it('should compute totalTimeSpent across all users', async () => {
const users: LWApiUser[] = [
createRawApiUser({ id: 'u1', email: 'a@test.com', course_stats: { total_time_spent: 1000 } }),
createRawApiUser({ id: 'u2', email: 'b@test.com', course_stats: { total_time_spent: 2500 } }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(users as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.totalTimeSpent).toBe(3500);
});
it('should rank mostActiveUsers by completedCourses descending, limited to 5', async () => {
const users: LWApiUser[] = Array.from({ length: 7 }, (_, i) =>
createRawApiUser({
id: `u${i}`,
email: `user${i}@test.com`,
first_name: `User${i}`,
last_name: 'Test',
full_name: `User${i} Test`,
course_stats: { total: 10, completed: (i + 1) * 2, in_progress: 1, total_time_spent: 100 },
}),
);
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(users as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.mostActiveUsers).toHaveLength(5);
// Most completed first
expect(result.Summary.mostActiveUsers[0].completedCourses).toBe(14); // (6+1)*2 = 14
expect(result.Summary.mostActiveUsers[0].name).toBe('User6 Test');
// Verify descending order
for (let i = 1; i < result.Summary.mostActiveUsers.length; i++) {
const prev = result.Summary.mostActiveUsers[i - 1].completedCourses ?? 0;
const curr = result.Summary.mostActiveUsers[i].completedCourses ?? 0;
expect(prev).toBeGreaterThanOrEqual(curr);
}
});
it('should exclude users with zero completedCourses from mostActiveUsers', async () => {
const users: LWApiUser[] = [
createRawApiUser({ id: 'u1', email: 'a@test.com', course_stats: { completed: 0 } }),
createRawApiUser({ id: 'u2', email: 'b@test.com', course_stats: { completed: 5 } }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(users as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.mostActiveUsers).toHaveLength(1);
expect(result.Summary.mostActiveUsers[0].id).toBe('u2');
});
it('should find recentSignups within the last 30 days', async () => {
// Fix "now" so the 30-day window is deterministic
const fakeNow = new Date('2024-08-01T00:00:00Z');
vi.useFakeTimers({ now: fakeNow });
const recentDate = '2024-07-20T10:00:00Z'; // within 30 days of fakeNow
const oldDate = '2024-01-01T10:00:00Z'; // outside 30 days
const users: LWApiUser[] = [
createRawApiUser({ id: 'recent-1', email: 'new@test.com', created: recentDate, first_name: 'New', full_name: 'New User' }),
createRawApiUser({ id: 'old-1', email: 'old@test.com', created: oldDate, first_name: 'Old', full_name: 'Old User' }),
];
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(users as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.recentSignups).toHaveLength(1);
expect(result.Summary.recentSignups[0].id).toBe('recent-1');
expect(result.Summary.recentSignups[0].name).toBe('New User');
expect(result.Summary.recentSignups[0].signupDate).toBeInstanceOf(Date);
vi.useRealTimers();
});
it('should limit recentSignups to 10 entries', async () => {
const fakeNow = new Date('2024-08-01T00:00:00Z');
vi.useFakeTimers({ now: fakeNow });
const users: LWApiUser[] = Array.from({ length: 15 }, (_, i) =>
createRawApiUser({
id: `recent-${i}`,
email: `user${i}@test.com`,
full_name: `User ${i}`,
created: `2024-07-${String(15 + i).padStart(2, '0')}T10:00:00Z`,
}),
);
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue(users as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Summary.recentSignups.length).toBeLessThanOrEqual(10);
vi.useRealTimers();
});
it('should handle an empty user list', async () => {
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.TotalCount).toBe(0);
expect(result.Users).toEqual([]);
expect(result.Summary.totalUsers).toBe(0);
expect(result.Summary.activeUsers).toBe(0);
expect(result.Summary.inactiveUsers).toBe(0);
expect(result.Summary.suspendedUsers).toBe(0);
expect(result.Summary.usersByRole).toEqual({});
expect(result.Summary.averageCoursesPerUser).toBe(0);
expect(result.Summary.totalTimeSpent).toBe(0);
expect(result.Summary.mostActiveUsers).toEqual([]);
expect(result.Summary.recentSignups).toEqual([]);
});
it('should propagate errors from the paginated request', async () => {
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockRejectedValue(new Error('Network failure'));
await expect(action.GetUsers({ CompanyID: 'comp-1' }, contextUser)).rejects.toThrow('Network failure');
});
it('should construct fullName from first_name and last_name when full_name is missing', async () => {
const rawUser = createRawApiUser({ full_name: undefined, first_name: 'Jane', last_name: 'Doe' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawUser] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Users[0].fullName).toBe('Jane Doe');
});
it('should default username to email when username is missing', async () => {
const rawUser = createRawApiUser({ username: undefined, email: 'fallback@example.com' });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawUser] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Users[0].username).toBe('fallback@example.com');
});
it('should default role to student when not specified', async () => {
const rawUser = createRawApiUser({ role: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawUser] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Users[0].role).toBe('student');
});
it('should default status to active when not specified', async () => {
const rawUser = createRawApiUser({ status: undefined });
vi.spyOn(action as never, 'makeLearnWorldsPaginatedRequest').mockResolvedValue([rawUser] as never);
const result = await action.GetUsers({ CompanyID: 'comp-1' }, contextUser);
expect(result.Users[0].status).toBe('active');
});
});
describe('InternalRunAction()', () => {
it('should return success when GetUsers succeeds', async () => {
const mockUsers: LearnWorldsUser[] = [
{
id: 'user-1',
email: 'alice@example.com',
username: 'alice',
firstName: 'Alice',
lastName: 'Smith',
fullName: 'Alice Smith',
status: 'active',
role: 'student',
createdAt: new Date('2024-06-15T10:00:00Z'),
totalCourses: 5,
completedCourses: 3,
inProgressCourses: 2,
totalTimeSpent: 7200,
},
];
vi.spyOn(action, 'GetUsers').mockResolvedValue({
Users: mockUsers,
TotalCount: 1,
Summary: {
totalUsers: 1,
activeUsers: 1,
inactiveUsers: 0,
suspendedUsers: 0,
usersByRole: { student: 1 },
averageCoursesPerUser: 5,
totalTimeSpent: 7200,
mostActiveUsers: [{ id: 'user-1', name: 'Alice Smith', completedCourses: 3 }],
recentSignups: [],
},
});
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'SearchText', Type: 'Input', Value: undefined },
{ Name: 'Role', Type: 'Input', Value: undefined },
{ Name: 'Status', Type: 'Input', Value: undefined },
{ Name: 'MaxResults', Type: 'Input', Value: undefined },
],
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 1 users from LearnWorlds');
});
it('should return error result when GetUsers throws', async () => {
vi.spyOn(action, 'GetUsers').mockRejectedValue(new Error('API connection failed'));
const runParams: RunActionParams = {
Params: [{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }],
ContextUser: contextUser,
} as unknown as RunActionParams;
const result = (await action['InternalRunAction'](runParams)) as ActionResultSimple;
expect(result.Success).toBe(false);
expect(result.ResultCode).toBe('ERROR');
expect(result.Message).toContain('API connection failed');
});
it('should return error when ContextUser is missing', async () => {
const runParams: RunActionParams = {
Params: [{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }],
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');
});
it('should handle non-Error thrown values', async () => {
vi.spyOn(action, 'GetUsers').mockRejectedValue('string error');
const runParams: RunActionParams = {
Params: [{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' }],
ContextUser: contextUser,
} as unknown as RunActionParams;
const result = (await action['InternalRunAction'](runParams)) as ActionResultSimple;
expect(result.Success).toBe(false);
expect(result.ResultCode).toBe('ERROR');
expect(result.Message).toContain('Unknown error occurred');
});
});
});