273 lines
9.1 KiB
TypeScript
273 lines
9.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ContentType } from '@cio/utils/constants';
|
|
import { getCourseById } from '@cio/db/queries/course';
|
|
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
|
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
|
import { getOrganizationById } from '@cio/db/queries/organization';
|
|
import { updateCourse } from '@cio/core/services/course/course';
|
|
import { ensureCourseSlug, generateUniqueCourseSlug } from '@cio/core/services/course/landing-page';
|
|
import {
|
|
evaluateCourseGoLiveReadiness,
|
|
getCourseGoLiveReadiness,
|
|
publishCourseWhenReady
|
|
} from '@cio/core/services/course/go-live-readiness';
|
|
|
|
vi.mock('@cio/db/queries/course', () => ({
|
|
getCourseById: vi.fn()
|
|
}));
|
|
|
|
vi.mock('@cio/db/queries/course/content', () => ({
|
|
getCourseContentItems: vi.fn()
|
|
}));
|
|
|
|
vi.mock('@cio/db/queries/tag', () => ({
|
|
getCourseOrganizationId: vi.fn()
|
|
}));
|
|
|
|
vi.mock('@cio/db/queries/organization', () => ({
|
|
getOrganizationById: vi.fn()
|
|
}));
|
|
|
|
vi.mock('@cio/core/services/course/course', () => ({
|
|
updateCourse: vi.fn()
|
|
}));
|
|
|
|
vi.mock('@cio/core/services/course/landing-page', () => ({
|
|
ensureCourseSlug: vi.fn(),
|
|
generateUniqueCourseSlug: vi.fn()
|
|
}));
|
|
|
|
vi.mock('@cio/core/config/dashboard-url', () => ({
|
|
getDashboardBaseUrl: vi.fn(() => 'https://school.example.com')
|
|
}));
|
|
|
|
const mockedGetCourseById = vi.mocked(getCourseById);
|
|
const mockedGetCourseContentItems = vi.mocked(getCourseContentItems);
|
|
const mockedGetCourseOrganizationId = vi.mocked(getCourseOrganizationId);
|
|
const mockedGetOrganizationById = vi.mocked(getOrganizationById);
|
|
const mockedUpdateCourse = vi.mocked(updateCourse);
|
|
const mockedEnsureCourseSlug = vi.mocked(ensureCourseSlug);
|
|
const mockedGenerateUniqueCourseSlug = vi.mocked(generateUniqueCourseSlug);
|
|
|
|
function buildCourse(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'course-1',
|
|
title: 'Workplace Safety Essentials',
|
|
description: 'A practical course for safer day-to-day workplace decisions.',
|
|
overview: 'Learn the policies, habits, and reporting steps that keep teams safe.',
|
|
slug: 'workplace-safety-essentials',
|
|
logo: 'https://example.com/banner.jpg',
|
|
bannerImage: null,
|
|
metadata: {
|
|
description: 'A public-facing overview of the safety course.',
|
|
goals: 'Identify hazards, report incidents, and follow safe operating procedures.',
|
|
requirements: 'No prior training required.',
|
|
instructor: {
|
|
name: 'Ada Instructor',
|
|
role: 'Safety Lead',
|
|
coursesNo: 4,
|
|
description: 'Experienced workplace safety instructor.',
|
|
imgUrl: ''
|
|
},
|
|
reviews: [
|
|
{
|
|
id: 1,
|
|
hide: false,
|
|
name: 'Learner',
|
|
avatar_url: '',
|
|
rating: 5,
|
|
created_at: 1,
|
|
description: 'Clear and useful.'
|
|
}
|
|
],
|
|
allowNewStudent: true
|
|
},
|
|
type: 'SELF_PACED',
|
|
cost: 0,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function buildContentItem(overrides: Partial<CourseContentItemRow> = {}): CourseContentItemRow {
|
|
return {
|
|
id: 'lesson-1',
|
|
type: ContentType.Lesson,
|
|
title: 'Introduction',
|
|
order: 0,
|
|
createdAt: null,
|
|
sectionId: null,
|
|
isUnlocked: true,
|
|
isComplete: false,
|
|
lessonAt: null,
|
|
callUrl: null,
|
|
hasNoteContent: true,
|
|
hasSlideContent: false,
|
|
videosCount: 0,
|
|
documentsCount: 0,
|
|
questionCount: null,
|
|
dueBy: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
describe('evaluateCourseGoLiveReadiness', () => {
|
|
it('returns blockers for missing course, landing page, and content requirements', () => {
|
|
const readiness = evaluateCourseGoLiveReadiness({
|
|
course: buildCourse({
|
|
title: 'Untitled course',
|
|
description: '',
|
|
overview: 'Welcome to this amazing course',
|
|
slug: null,
|
|
logo: '',
|
|
metadata: { allowNewStudent: true }
|
|
}) as never,
|
|
contentItems: [],
|
|
organization: null
|
|
});
|
|
|
|
expect(readiness.ready).toBe(false);
|
|
expect(readiness.blockers.map((blocker) => blocker.code)).toEqual(
|
|
expect.arrayContaining([
|
|
'COURSE_TITLE_MISSING',
|
|
'COURSE_DESCRIPTION_MISSING',
|
|
'COURSE_SLUG_MISSING',
|
|
'LANDING_OVERVIEW_MISSING',
|
|
'LANDING_DESCRIPTION_MISSING',
|
|
'LANDING_GOALS_MISSING',
|
|
'LANDING_REQUIREMENTS_MISSING',
|
|
'LANDING_IMAGE_MISSING',
|
|
'COURSE_CONTENT_MISSING'
|
|
])
|
|
);
|
|
});
|
|
|
|
it('flags empty lessons and exercises without questions', () => {
|
|
const readiness = evaluateCourseGoLiveReadiness({
|
|
course: buildCourse() as never,
|
|
contentItems: [
|
|
buildContentItem({ hasNoteContent: false, hasSlideContent: false }),
|
|
buildContentItem({
|
|
id: 'exercise-1',
|
|
type: ContentType.Exercise,
|
|
title: 'Final quiz',
|
|
questionCount: 0
|
|
})
|
|
],
|
|
organization: null
|
|
});
|
|
|
|
expect(readiness.ready).toBe(false);
|
|
expect(readiness.blockers.map((blocker) => blocker.code)).toEqual(
|
|
expect.arrayContaining(['LESSON_CONTENT_EMPTY', 'EXERCISE_QUESTIONS_MISSING'])
|
|
);
|
|
});
|
|
|
|
it('returns ready when required fields and content are complete', () => {
|
|
const readiness = evaluateCourseGoLiveReadiness({
|
|
course: buildCourse() as never,
|
|
contentItems: [buildContentItem()],
|
|
organization: {
|
|
customDomain: 'school.example.com',
|
|
isCustomDomainVerified: true,
|
|
siteName: 'school'
|
|
} as never
|
|
});
|
|
|
|
expect(readiness.ready).toBe(true);
|
|
expect(readiness.blockers).toEqual([]);
|
|
expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials');
|
|
});
|
|
|
|
it('blocks publishing a compliance course that has no completion deadline', () => {
|
|
const readiness = evaluateCourseGoLiveReadiness({
|
|
course: buildCourse({
|
|
type: 'COMPLIANCE',
|
|
certificate: { isDownloadable: true }
|
|
}) as never,
|
|
contentItems: [buildContentItem()],
|
|
organization: {
|
|
customDomain: 'school.example.com',
|
|
isCustomDomainVerified: true,
|
|
siteName: 'school'
|
|
} as never
|
|
});
|
|
|
|
expect(readiness.ready).toBe(false);
|
|
expect(readiness.blockers.map((blocker) => blocker.code)).toContain('COMPLIANCE_DEADLINE_MISSING');
|
|
});
|
|
|
|
it('allows publishing a compliance course when a completion deadline is set', () => {
|
|
const readiness = evaluateCourseGoLiveReadiness({
|
|
course: buildCourse({
|
|
type: 'COMPLIANCE',
|
|
certificate: { deadline: '2026-12-31T23:59:00.000Z' }
|
|
}) as never,
|
|
contentItems: [buildContentItem()],
|
|
organization: {
|
|
customDomain: 'school.example.com',
|
|
isCustomDomainVerified: true,
|
|
siteName: 'school'
|
|
} as never
|
|
});
|
|
|
|
expect(readiness.ready).toBe(true);
|
|
expect(readiness.blockers).toEqual([]);
|
|
expect(readiness.warnings.map((warning) => warning.code)).toContain('COMPLIANCE_REVIEW_RECOMMENDED');
|
|
});
|
|
});
|
|
|
|
describe('course go-live service', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedGetCourseOrganizationId.mockResolvedValue('org-1');
|
|
mockedGetOrganizationById.mockResolvedValue({
|
|
customDomain: 'school.example.com',
|
|
isCustomDomainVerified: true,
|
|
siteName: 'school'
|
|
} as never);
|
|
mockedGenerateUniqueCourseSlug.mockResolvedValue('workplace-safety-essentials');
|
|
});
|
|
|
|
it('suggests a unique slug when a ready course has no slug', async () => {
|
|
mockedGetCourseById.mockResolvedValue([buildCourse({ slug: null })] as never);
|
|
mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never);
|
|
|
|
const readiness = await getCourseGoLiveReadiness('course-1');
|
|
|
|
expect(readiness.ready).toBe(true);
|
|
expect(readiness.suggestedFixes.slug).toBe('workplace-safety-essentials');
|
|
expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials');
|
|
expect(mockedGenerateUniqueCourseSlug).toHaveBeenCalledWith('workplace-safety-essentials');
|
|
});
|
|
|
|
it('does not publish when readiness blockers remain', async () => {
|
|
mockedGetCourseById.mockResolvedValue([buildCourse({ description: '' })] as never);
|
|
mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never);
|
|
|
|
await expect(publishCourseWhenReady('course-1')).rejects.toThrow('Course is not ready to go live');
|
|
expect(mockedUpdateCourse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('generates a slug and publishes when the course is ready', async () => {
|
|
const course = buildCourse({ slug: null });
|
|
mockedGetCourseById.mockResolvedValue([course] as never);
|
|
mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never);
|
|
mockedEnsureCourseSlug.mockResolvedValue('workplace-safety-essentials');
|
|
mockedUpdateCourse.mockResolvedValue({
|
|
...course,
|
|
slug: 'workplace-safety-essentials',
|
|
isPublished: true
|
|
} as never);
|
|
|
|
const result = await publishCourseWhenReady('course-1');
|
|
|
|
expect(mockedEnsureCourseSlug).toHaveBeenCalledWith('course-1', course.title);
|
|
expect(mockedUpdateCourse).toHaveBeenCalledWith('course-1', {
|
|
slug: 'workplace-safety-essentials',
|
|
isPublished: true
|
|
});
|
|
expect(result.course.isPublished).toBe(true);
|
|
expect(result.readiness.ready).toBe(true);
|
|
});
|
|
});
|