@memberjunction/actions-bizapps-lms
Version:
LMS system integration actions for MemberJunction
576 lines (494 loc) • 21.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 { GetQuizResultsAction } from '../providers/learnworlds/actions/get-quiz-results.action';
import { ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
import {
FormattedQuizResult,
FormattedAnswer,
QuizMetrics,
QuizResultsSummary,
} 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 quiz result object
*/
function createRawApiQuizResult(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'result-1',
user_id: 'user-123',
course_id: 'course-abc',
quiz_id: 'quiz-xyz',
attempt_number: 1,
score: 8,
max_score: 10,
percentage: 80,
passed: true,
passing_score: 70,
started_at: '2024-06-01T09:00:00Z',
completed_at: '2024-06-01T09:30:00Z',
duration: 1800,
user: {
id: 'user-123',
email: 'student@example.com',
name: 'Jane Doe',
},
quiz: {
id: 'quiz-xyz',
title: 'Module 1 Quiz',
type: 'quiz',
question_count: 10,
},
...overrides,
};
}
/**
* Helper to build a mock API response wrapping quiz results
*/
function createApiResponse(rawResults: Record<string, unknown>[]): Record<string, unknown> {
return {
success: true,
data: rawResults,
};
}
/**
* Helper to build a mock quiz detail response (questions + answers)
*/
function createQuizDetailResponse(
questions: Record<string, unknown>[],
answers: Record<string, unknown>[],
): Record<string, unknown> {
return {
success: true,
data: {
questions,
answers,
},
};
}
describe('GetQuizResultsAction', () => {
let action: GetQuizResultsAction;
let contextUser: UserInfo;
beforeEach(() => {
action = new GetQuizResultsAction();
contextUser = createMockContextUser();
});
describe('GetQuizResults() typed method', () => {
it('should get quiz results successfully (happy path)', async () => {
const rawResult = createRawApiQuizResult();
const apiResponse = createApiResponse([rawResult]);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never) // main results call
.mockResolvedValueOnce(createQuizDetailResponse(
[{ question_number: 1, question_text: 'Q1?', question_type: 'multiple-choice', points: 1 }],
[{ question_number: 1, is_correct: true, points_earned: 1, points_possible: 1 }],
) as never); // details call
const result = await action.GetQuizResults({ CompanyID: 'comp-1', UserID: 'user-123' }, contextUser);
expect(result.TotalCount).toBe(1);
expect(result.QuizResults).toHaveLength(1);
const qr: FormattedQuizResult = result.QuizResults[0];
expect(qr.id).toBe('result-1');
expect(qr.userId).toBe('user-123');
expect(qr.courseId).toBe('course-abc');
expect(qr.quizId).toBe('quiz-xyz');
expect(qr.attemptNumber).toBe(1);
expect(qr.score).toBe(8);
expect(qr.maxScore).toBe(10);
expect(qr.percentage).toBe(80);
expect(qr.passed).toBe(true);
expect(qr.passingScore).toBe(70);
expect(qr.startedAt).toBe('2024-06-01T09:00:00Z');
expect(qr.completedAt).toBe('2024-06-01T09:30:00Z');
expect(qr.duration).toBe(1800);
});
it('should map result fields with fallback values', async () => {
const rawResult = createRawApiQuizResult({
id: undefined,
result_id: 'fallback-result-id',
attempt_number: undefined,
attempt: 3,
max_score: undefined,
total_points: 50,
percentage: undefined,
score: 25,
passed: undefined,
is_passing: true,
passing_score: undefined,
passing_percentage: 60,
completed_at: undefined,
submitted_at: '2024-07-01T10:00:00Z',
duration: undefined,
time_spent: 900,
user: undefined,
quiz: undefined,
});
const apiResponse = createApiResponse([rawResult]);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never) // main results
.mockResolvedValueOnce({ success: false } as never); // quiz lookup (quiz_id present, quiz undefined)
const result = await action.GetQuizResults(
{ CompanyID: 'comp-1', UserID: 'user-123', IncludeQuestions: false, IncludeAnswers: false },
contextUser,
);
const qr = result.QuizResults[0];
expect(qr.id).toBe('fallback-result-id');
expect(qr.attemptNumber).toBe(3);
expect(qr.maxScore).toBe(50);
expect(qr.percentage).toBe(50); // 25/50 * 100
expect(qr.passed).toBe(true);
expect(qr.passingScore).toBe(60);
expect(qr.completedAt).toBe('2024-07-01T10:00:00Z');
expect(qr.duration).toBe(900);
});
it('should attach user info when present in raw result', async () => {
const rawResult = createRawApiQuizResult({
user: {
id: 'u-42',
email: 'alice@test.com',
first_name: 'Alice',
last_name: 'Smith',
},
});
const apiResponse = createApiResponse([rawResult]);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never)
.mockResolvedValueOnce({ success: false } as never);
const result = await action.GetQuizResults({ CompanyID: 'comp-1', UserID: 'u-42' }, contextUser);
expect(result.QuizResults[0].user).toBeDefined();
expect(result.QuizResults[0].user!.id).toBe('u-42');
expect(result.QuizResults[0].user!.email).toBe('alice@test.com');
expect(result.QuizResults[0].user!.name).toBe('Alice Smith');
});
it('should include questions when IncludeQuestions is true (default)', async () => {
const rawResult = createRawApiQuizResult();
const apiResponse = createApiResponse([rawResult]);
const detailResponse = createQuizDetailResponse(
[
{ question_number: 1, question_id: 'q1', question_text: 'What is 2+2?', question_type: 'multiple-choice', points: 2, difficulty: 'easy' },
{ question_number: 2, question_id: 'q2', question_text: 'Explain gravity.', question_type: 'essay', points: 5, difficulty: 'hard' },
],
[
{ question_number: 1, is_correct: true, points_earned: 2, points_possible: 2 },
{ question_number: 2, is_correct: false, points_earned: 3, points_possible: 5 },
],
);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never)
.mockResolvedValueOnce(detailResponse as never);
const result = await action.GetQuizResults(
{ CompanyID: 'comp-1', UserID: 'user-123', IncludeQuestions: true, IncludeAnswers: false },
contextUser,
);
const qr = result.QuizResults[0];
expect(qr.questions).toBeDefined();
expect(qr.questions).toHaveLength(2);
expect(qr.questions![0].questionNumber).toBe(1);
expect(qr.questions![0].questionId).toBe('q1');
expect(qr.questions![0].questionText).toBe('What is 2+2?');
expect(qr.questions![0].questionType).toBe('multiple-choice');
expect(qr.questions![0].points).toBe(2);
expect(qr.questions![0].difficulty).toBe('easy');
expect(qr.questions![1].questionNumber).toBe(2);
expect(qr.questions![1].questionType).toBe('essay');
// IncludeAnswers was false, so answers and metrics should not be set
expect(qr.answers).toBeUndefined();
expect(qr.metrics).toBeUndefined();
});
it('should include answers and calculate metrics when IncludeAnswers is true', async () => {
const rawResult = createRawApiQuizResult();
const apiResponse = createApiResponse([rawResult]);
const detailResponse = createQuizDetailResponse(
[
{ question_number: 1, question_text: 'Q1' },
{ question_number: 2, question_text: 'Q2' },
{ question_number: 3, question_text: 'Q3' },
],
[
{ question_number: 1, user_answer: 'A', correct_answer: 'A', is_correct: true, points_earned: 1, points_possible: 1 },
{ question_number: 2, user_answer: 'B', correct_answer: 'C', is_correct: false, points_earned: 0, points_possible: 1 },
{ question_number: 3, user_answer: 'D', correct_answer: 'D', is_correct: true, points_earned: 1, points_possible: 1 },
],
);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never)
.mockResolvedValueOnce(detailResponse as never);
const result = await action.GetQuizResults(
{ CompanyID: 'comp-1', UserID: 'user-123', IncludeQuestions: false, IncludeAnswers: true },
contextUser,
);
const qr = result.QuizResults[0];
expect(qr.answers).toBeDefined();
expect(qr.answers).toHaveLength(3);
const answers: FormattedAnswer[] = qr.answers!;
expect(answers[0].isCorrect).toBe(true);
expect(answers[0].userAnswer).toBe('A');
expect(answers[0].correctAnswer).toBe('A');
expect(answers[0].pointsEarned).toBe(1);
expect(answers[1].isCorrect).toBe(false);
expect(answers[1].userAnswer).toBe('B');
// Metrics should be calculated from answers
const metrics: QuizMetrics = qr.metrics!;
expect(metrics).toBeDefined();
expect(metrics.correctAnswers).toBe(2);
expect(metrics.incorrectAnswers).toBe(1);
expect(metrics.totalQuestions).toBe(3);
expect(metrics.accuracyRate).toBe(66.7);
});
it('should skip detailed results when both IncludeQuestions and IncludeAnswers are false', async () => {
const rawResult = createRawApiQuizResult();
const apiResponse = createApiResponse([rawResult]);
const requestSpy = vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never);
const result = await action.GetQuizResults(
{ CompanyID: 'comp-1', UserID: 'user-123', IncludeQuestions: false, IncludeAnswers: false },
contextUser,
);
// Should only make one API call (no details call)
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(result.QuizResults[0].questions).toBeUndefined();
expect(result.QuizResults[0].answers).toBeUndefined();
expect(result.QuizResults[0].metrics).toBeUndefined();
});
it('should build summary with pass/fail rates', async () => {
const rawResults = [
createRawApiQuizResult({ id: 'r1', score: 9, max_score: 10, percentage: 90, passed: true, duration: 1200, quiz: { id: 'q1', title: 'Quiz A', type: 'quiz' } }),
createRawApiQuizResult({ id: 'r2', score: 5, max_score: 10, percentage: 50, passed: false, duration: 600, quiz: { id: 'q1', title: 'Quiz A', type: 'quiz' } }),
createRawApiQuizResult({ id: 'r3', score: 8, max_score: 10, percentage: 80, passed: true, duration: 900, quiz: { id: 'q2', title: 'Quiz B', type: 'quiz' } }),
];
const apiResponse = createApiResponse(rawResults);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never)
.mockResolvedValue({ success: false } as never); // detail calls fail gracefully
const result = await action.GetQuizResults(
{ CompanyID: 'comp-1', UserID: 'user-123', IncludeQuestions: false, IncludeAnswers: false },
contextUser,
);
const summary: QuizResultsSummary = result.Summary;
expect(summary.totalResults).toBe(3);
expect(summary.passedResults).toBe(2);
expect(summary.failedResults).toBe(1);
// passRate = (2/3)*100 = 66.7
expect(summary.passRate).toBeCloseTo(66.7, 0);
// averageScore = (90 + 50 + 80) / 3 = 73.3
expect(summary.averageScore).toBeCloseTo(73.3, 0);
// averageDuration = (1200 + 600 + 900) / 3 = 900
expect(summary.averageDuration).toBe(900);
expect(summary.filterType).toBe('user');
});
it('should build summary with quiz breakdown when no specific QuizID is given', async () => {
const rawResults = [
createRawApiQuizResult({ id: 'r1', percentage: 90, passed: true, quiz: { id: 'q1', title: 'Quiz Alpha', type: 'quiz' } }),
createRawApiQuizResult({ id: 'r2', percentage: 60, passed: false, quiz: { id: 'q1', title: 'Quiz Alpha', type: 'quiz' } }),
createRawApiQuizResult({ id: 'r3', percentage: 100, passed: true, quiz: { id: 'q2', title: 'Quiz Beta', type: 'quiz' } }),
];
const apiResponse = createApiResponse(rawResults);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never)
.mockResolvedValue({ success: false } as never);
const result = await action.GetQuizResults(
{ CompanyID: 'comp-1', UserID: 'user-123', IncludeQuestions: false, IncludeAnswers: false },
contextUser,
);
const breakdown = result.Summary.quizBreakdown;
expect(breakdown).not.toBeNull();
expect(breakdown!['Quiz Alpha']).toBeDefined();
expect(breakdown!['Quiz Alpha'].stats.attempts).toBe(2);
expect(breakdown!['Quiz Alpha'].stats.passRate).toBe(50);
expect(breakdown!['Quiz Beta']).toBeDefined();
expect(breakdown!['Quiz Beta'].stats.attempts).toBe(1);
expect(breakdown!['Quiz Beta'].stats.passRate).toBe(100);
});
it('should handle empty results', async () => {
const apiResponse = createApiResponse([]);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never);
const result = await action.GetQuizResults({ CompanyID: 'comp-1', CourseID: 'course-1' }, contextUser);
expect(result.TotalCount).toBe(0);
expect(result.QuizResults).toEqual([]);
expect(result.Summary.totalResults).toBe(0);
expect(result.Summary.passRate).toBe(0);
expect(result.Summary.averageScore).toBe(0);
expect(result.Summary.averageDuration).toBe(0);
});
it('should handle nested data.data response format', async () => {
const rawResult = createRawApiQuizResult();
const nestedResponse = {
success: true,
data: { data: [rawResult] },
};
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(nestedResponse as never)
.mockResolvedValueOnce({ success: false } as never);
const result = await action.GetQuizResults(
{ CompanyID: 'comp-1', QuizID: 'quiz-xyz', IncludeQuestions: false, IncludeAnswers: false },
contextUser,
);
expect(result.TotalCount).toBe(1);
expect(result.QuizResults[0].id).toBe('result-1');
});
it('should throw when API response indicates failure', async () => {
const failResponse = { success: false, message: 'Quiz not found' };
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(failResponse as never);
await expect(
action.GetQuizResults({ CompanyID: 'comp-1', QuizID: 'bad-quiz' }, contextUser),
).rejects.toThrow('Quiz not found');
});
it('should throw when no identifier is provided', async () => {
await expect(
action.GetQuizResults({ CompanyID: 'comp-1' }, contextUser),
).rejects.toThrow('At least one of UserID, CourseID, or QuizID is required');
});
it('should propagate errors from the API layer', async () => {
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockRejectedValueOnce(new Error('Network timeout'));
await expect(
action.GetQuizResults({ CompanyID: 'comp-1', UserID: 'user-1' }, contextUser),
).rejects.toThrow('Network timeout');
});
it('should resolve quiz info via lookup when result has no inline quiz data', async () => {
const rawResult = createRawApiQuizResult({
quiz: undefined,
quiz_id: 'quiz-remote',
});
const apiResponse = createApiResponse([rawResult]);
const quizLookupResponse = {
success: true,
data: {
id: 'quiz-remote',
title: 'Remote Quiz',
type: 'assessment',
question_count: 5,
},
};
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValueOnce(apiResponse as never) // main results
.mockResolvedValueOnce(quizLookupResponse as never) // quiz lookup
.mockResolvedValueOnce({ success: false } as never); // details (fail gracefully)
const result = await action.GetQuizResults(
{ CompanyID: 'comp-1', UserID: 'user-123', IncludeQuestions: false, IncludeAnswers: false },
contextUser,
);
expect(result.QuizResults[0].quiz).toBeDefined();
expect(result.QuizResults[0].quiz!.id).toBe('quiz-remote');
expect(result.QuizResults[0].quiz!.title).toBe('Remote Quiz');
expect(result.QuizResults[0].quiz!.type).toBe('assessment');
expect(result.QuizResults[0].quiz!.questionCount).toBe(5);
});
it('should set filterType based on provided identifiers', async () => {
const apiResponse = createApiResponse([]);
vi.spyOn(action as never, 'makeLearnWorldsRequest')
.mockResolvedValue(apiResponse as never);
// user-course combination
const ucResult = await action.GetQuizResults(
{ CompanyID: 'comp-1', UserID: 'u1', CourseID: 'c1' },
contextUser,
);
expect(ucResult.Summary.filterType).toBe('user-course');
// course only
const cResult = await action.GetQuizResults(
{ CompanyID: 'comp-1', CourseID: 'c1' },
contextUser,
);
expect(cResult.Summary.filterType).toBe('course');
});
});
describe('InternalRunAction()', () => {
it('should return success when GetQuizResults succeeds', async () => {
const mockResults: FormattedQuizResult[] = [
{
id: 'r1',
userId: 'u1',
courseId: 'c1',
quizId: 'q1',
attemptNumber: 1,
score: 9,
maxScore: 10,
percentage: 90,
passed: true,
passingScore: 70,
duration: 600,
durationText: '10m 0s',
},
];
const mockSummary: QuizResultsSummary = {
totalResults: 1,
passedResults: 1,
failedResults: 0,
passRate: 100,
averageScore: 90,
averageDuration: 600,
averageDurationText: '10m 0s',
dateRange: { from: 'all-time', to: 'current' },
filterType: 'user',
quizBreakdown: null,
};
vi.spyOn(action, 'GetQuizResults').mockResolvedValue({
QuizResults: mockResults,
TotalCount: 1,
Summary: mockSummary,
});
const runParams: RunActionParams = {
Params: [
{ Name: 'CompanyID', Type: 'Input', Value: 'comp-1' },
{ Name: 'UserID', Type: 'Input', Value: 'u1' },
],
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('Retrieved 1 quiz result(s)');
});
it('should return error result when GetQuizResults throws', async () => {
vi.spyOn(action, 'GetQuizResults').mockRejectedValue(new Error('LearnWorlds API error: 403 Forbidden'));
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('Error retrieving quiz results');
expect(result.Message).toContain('LearnWorlds API error: 403 Forbidden');
});
});
});