feat: widgets and ai chat
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeAgentLessonContent } from '../services/agent/lesson-content';
|
||||
|
||||
describe('normalizeAgentLessonContent', () => {
|
||||
it('removes a leading heading that duplicates the lesson title', () => {
|
||||
const normalized = normalizeAgentLessonContent(
|
||||
'<h3>Introduction to Functions</h3><p>Functions map inputs to outputs.</p>',
|
||||
'Introduction to Functions'
|
||||
);
|
||||
|
||||
expect(normalized).toBe('<p>Functions map inputs to outputs.</p>');
|
||||
});
|
||||
|
||||
it('removes a leading bold paragraph that duplicates the lesson title', () => {
|
||||
const normalized = normalizeAgentLessonContent(
|
||||
'<p><strong>Introduction to Functions</strong></p><p>Functions map inputs to outputs.</p>',
|
||||
'Introduction to Functions'
|
||||
);
|
||||
|
||||
expect(normalized).toBe('<p>Functions map inputs to outputs.</p>');
|
||||
});
|
||||
|
||||
it('rewrites h1 and h2 tags to h3', () => {
|
||||
const normalized = normalizeAgentLessonContent(
|
||||
'<h1>Overview</h1><h2>Key Ideas</h2><p>Body</p>',
|
||||
'Different Lesson Title'
|
||||
);
|
||||
|
||||
expect(normalized).toBe('<h3>Overview</h3><h3>Key Ideas</h3><p>Body</p>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
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 '../services/course/course';
|
||||
import { ensureCourseSlug, generateUniqueCourseSlug } from '../services/course/landing-page';
|
||||
import {
|
||||
evaluateCourseGoLiveReadiness,
|
||||
getCourseGoLiveReadiness,
|
||||
publishCourseWhenReady
|
||||
} from '../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('../services/course/course', () => ({
|
||||
updateCourse: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../services/course/landing-page', () => ({
|
||||
buildCourseBaseUrl: vi.fn(() => 'https://school.example.com'),
|
||||
ensureCourseSlug: vi.fn(),
|
||||
generateUniqueCourseSlug: vi.fn()
|
||||
}));
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildUpdatedQuestions,
|
||||
type CurrentQuestionForPatch,
|
||||
type QuestionPatch
|
||||
} from '@api/services/agent/question-update';
|
||||
import { QUESTION_TYPE } from '@cio/utils/validation/constants';
|
||||
|
||||
const EXERCISE_ID = 'ex-1';
|
||||
|
||||
function makeQuestion(overrides: Partial<CurrentQuestionForPatch>): CurrentQuestionForPatch {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Existing question',
|
||||
questionTypeId: QUESTION_TYPE.RADIO,
|
||||
points: 1,
|
||||
order: 0,
|
||||
settings: {},
|
||||
options: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildUpdatedQuestions', () => {
|
||||
describe('option-based question types (correct answer on options[].isCorrect)', () => {
|
||||
it('RADIO: flips the correct option without losing siblings', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.RADIO,
|
||||
options: [
|
||||
{ id: 10, label: 'A', isCorrect: true },
|
||||
{ id: 11, label: 'B', isCorrect: false },
|
||||
{ id: 12, label: 'C', isCorrect: false }
|
||||
]
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
options: [
|
||||
{ id: 10, isCorrect: false },
|
||||
{ id: 11, isCorrect: true },
|
||||
{ id: 12, isCorrect: false }
|
||||
]
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.options).toEqual([
|
||||
{ id: 10, label: 'A', isCorrect: false, settings: undefined },
|
||||
{ id: 11, label: 'B', isCorrect: true, settings: undefined },
|
||||
{ id: 12, label: 'C', isCorrect: false, settings: undefined }
|
||||
]);
|
||||
});
|
||||
|
||||
it('CHECKBOX: can mark multiple options correct in a single patch', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.CHECKBOX,
|
||||
options: [
|
||||
{ id: 20, label: 'X', isCorrect: false },
|
||||
{ id: 21, label: 'Y', isCorrect: false },
|
||||
{ id: 22, label: 'Z', isCorrect: false }
|
||||
]
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
options: [
|
||||
{ id: 20, isCorrect: true },
|
||||
{ id: 21, isCorrect: true },
|
||||
{ id: 22, isCorrect: false }
|
||||
]
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.options.filter((o) => o.isCorrect).map((o) => o.id)).toEqual([20, 21]);
|
||||
});
|
||||
|
||||
it('TRUE_FALSE: switches the correct side', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.TRUE_FALSE,
|
||||
options: [
|
||||
{ id: 30, label: 'True', isCorrect: true },
|
||||
{ id: 31, label: 'False', isCorrect: false }
|
||||
]
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
options: [
|
||||
{ id: 30, isCorrect: false },
|
||||
{ id: 31, isCorrect: true }
|
||||
]
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
const correct = result.options.find((o) => o.isCorrect);
|
||||
expect(correct?.label).toBe('False');
|
||||
});
|
||||
|
||||
it('appends a new option when id is omitted (RADIO)', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.RADIO,
|
||||
options: [
|
||||
{ id: 40, label: 'A', isCorrect: true },
|
||||
{ id: 41, label: 'B', isCorrect: false }
|
||||
]
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
options: [
|
||||
{ id: 40, isCorrect: true },
|
||||
{ id: 41, isCorrect: false },
|
||||
{ label: 'C', isCorrect: false }
|
||||
]
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.options).toHaveLength(3);
|
||||
expect(result.options[2]).toEqual({ label: 'C', isCorrect: false, settings: undefined });
|
||||
expect(result.options[2].id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings-based question types (correct answer on question.settings)', () => {
|
||||
it('NUMERIC: writes settings.correctValue and preserves existing tolerance', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.NUMERIC,
|
||||
settings: { tolerance: 0.1, hint: 'a ray' }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
settings: { correctValue: 1 }
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.settings).toEqual({ tolerance: 0.1, hint: 'a ray', correctValue: 1 });
|
||||
expect(result.options).toEqual([]);
|
||||
});
|
||||
|
||||
it('NUMERIC: patching tolerance does not clobber correctValue', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.NUMERIC,
|
||||
settings: { correctValue: 42, tolerance: 0 }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
settings: { tolerance: 0.5 }
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.settings).toEqual({ correctValue: 42, tolerance: 0.5 });
|
||||
});
|
||||
|
||||
it('STAR: writes settings.correctValue', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.STAR,
|
||||
settings: { maxStars: 5 }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
settings: { correctValue: 4 }
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.settings).toEqual({ maxStars: 5, correctValue: 4 });
|
||||
});
|
||||
|
||||
it('WORD_BANK: writes correctAnswers and template together', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.WORD_BANK,
|
||||
settings: { template: 'A ___ has no end.', correctAnswers: ['line'] }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
settings: {
|
||||
template: 'A ___ extends in one direction; a ___ extends in both.',
|
||||
correctAnswers: ['ray', 'line']
|
||||
}
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.settings).toEqual({
|
||||
template: 'A ___ extends in one direction; a ___ extends in both.',
|
||||
correctAnswers: ['ray', 'line']
|
||||
});
|
||||
});
|
||||
|
||||
it('FILL_BLANK: settings shallow-merge preserves untouched keys', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.FILL_BLANK,
|
||||
settings: { caseSensitive: false, correctAnswers: ['old'] }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
settings: { correctAnswers: ['new'] }
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.settings).toEqual({ caseSensitive: false, correctAnswers: ['new'] });
|
||||
});
|
||||
|
||||
it('MATCHING: settings shallow-merge preserves pairs not in patch', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.MATCHING,
|
||||
settings: { pairs: [{ left: 'A', right: '1' }] }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
settings: {
|
||||
pairs: [
|
||||
{ left: 'A', right: '1' },
|
||||
{ left: 'B', right: '2' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.settings).toEqual({
|
||||
pairs: [
|
||||
{ left: 'A', right: '1' },
|
||||
{ left: 'B', right: '2' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('ORDERING: writes settings.correctOrder', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.ORDERING,
|
||||
settings: { items: ['x', 'y', 'z'] }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
settings: { correctOrder: ['z', 'y', 'x'] }
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.settings).toEqual({ items: ['x', 'y', 'z'], correctOrder: ['z', 'y', 'x'] });
|
||||
});
|
||||
|
||||
it('HOTSPOT: writes settings.correctRegion', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.HOTSPOT,
|
||||
settings: { imageUrl: 'https://example.com/img.png' }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
settings: { correctRegion: { x: 10, y: 20, w: 30, h: 30 } }
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.settings).toEqual({
|
||||
imageUrl: 'https://example.com/img.png',
|
||||
correctRegion: { x: 10, y: 20, w: 30, h: 30 }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('manually-graded / answer-free types', () => {
|
||||
it.each([
|
||||
['TEXTAREA', QUESTION_TYPE.TEXTAREA],
|
||||
['SHORT_ANSWER', QUESTION_TYPE.SHORT_ANSWER],
|
||||
['FILE_UPLOAD', QUESTION_TYPE.FILE_UPLOAD],
|
||||
['LINK', QUESTION_TYPE.LINK]
|
||||
])('%s: updates points and prompt without touching options', (_, questionTypeId) => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId,
|
||||
title: 'Old prompt',
|
||||
points: 1,
|
||||
settings: { rubric: 'be specific' }
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
question: 'New prompt',
|
||||
points: 5
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.question).toBe('New prompt');
|
||||
expect(result.points).toBe(5);
|
||||
expect(result.settings).toEqual({ rubric: 'be specific' });
|
||||
expect(result.options).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safety guarantees', () => {
|
||||
it('throws when the patched id is not in the exercise', () => {
|
||||
const current = makeQuestion({ id: 1 });
|
||||
expect(() => buildUpdatedQuestions([current], [{ id: 999, points: 5 }], EXERCISE_ID)).toThrowError(
|
||||
/does not belong to exercise/
|
||||
);
|
||||
});
|
||||
|
||||
it('omitting options preserves existing options (avoids the diff-deletes-all trap)', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.RADIO,
|
||||
options: [
|
||||
{ id: 50, label: 'A', isCorrect: true },
|
||||
{ id: 51, label: 'B', isCorrect: false }
|
||||
]
|
||||
});
|
||||
const patch: QuestionPatch = { id: 1, points: 3 };
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.options).toEqual([
|
||||
{ id: 50, label: 'A', isCorrect: true, settings: undefined },
|
||||
{ id: 51, label: 'B', isCorrect: false, settings: undefined }
|
||||
]);
|
||||
});
|
||||
|
||||
it('passing options drops any existing option missing from the array (downstream diff will delete it)', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.RADIO,
|
||||
options: [
|
||||
{ id: 60, label: 'A', isCorrect: true },
|
||||
{ id: 61, label: 'B', isCorrect: false },
|
||||
{ id: 62, label: 'C', isCorrect: false }
|
||||
]
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
options: [
|
||||
{ id: 60, isCorrect: true },
|
||||
{ id: 61, isCorrect: false }
|
||||
]
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.options.map((o) => o.id)).toEqual([60, 61]);
|
||||
});
|
||||
|
||||
it('omitting question/points/order/typeId backfills from current question', () => {
|
||||
const current = makeQuestion({
|
||||
title: 'Original',
|
||||
questionTypeId: QUESTION_TYPE.NUMERIC,
|
||||
points: 7,
|
||||
order: 3,
|
||||
settings: { correctValue: 5 }
|
||||
});
|
||||
const patch: QuestionPatch = { id: 1, settings: { correctValue: 9 } };
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.question).toBe('Original');
|
||||
expect(result.questionTypeId).toBe(QUESTION_TYPE.NUMERIC);
|
||||
expect(result.points).toBe(7);
|
||||
expect(result.order).toBe(3);
|
||||
expect(result.settings).toEqual({ correctValue: 9 });
|
||||
});
|
||||
|
||||
it('per-option settings shallow-merge logic: patch overrides; existing label/isCorrect preserved when omitted', () => {
|
||||
const current = makeQuestion({
|
||||
questionTypeId: QUESTION_TYPE.RADIO,
|
||||
options: [{ id: 70, label: 'Keep label', isCorrect: true, settings: { color: 'red' } }]
|
||||
});
|
||||
const patch: QuestionPatch = {
|
||||
id: 1,
|
||||
options: [{ id: 70, settings: { color: 'blue' } }]
|
||||
};
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.options[0]).toEqual({
|
||||
id: 70,
|
||||
label: 'Keep label',
|
||||
isCorrect: true,
|
||||
settings: { color: 'blue' }
|
||||
});
|
||||
});
|
||||
|
||||
it('handles multiple patches in one call independently', () => {
|
||||
const current: CurrentQuestionForPatch[] = [
|
||||
makeQuestion({ id: 1, questionTypeId: QUESTION_TYPE.NUMERIC, settings: { correctValue: 1 } }),
|
||||
makeQuestion({
|
||||
id: 2,
|
||||
questionTypeId: QUESTION_TYPE.RADIO,
|
||||
options: [
|
||||
{ id: 80, label: 'A', isCorrect: false },
|
||||
{ id: 81, label: 'B', isCorrect: true }
|
||||
]
|
||||
})
|
||||
];
|
||||
const patches: QuestionPatch[] = [
|
||||
{ id: 1, settings: { correctValue: 42 } },
|
||||
{
|
||||
id: 2,
|
||||
options: [
|
||||
{ id: 80, isCorrect: true },
|
||||
{ id: 81, isCorrect: false }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const result = buildUpdatedQuestions(current, patches, EXERCISE_ID);
|
||||
|
||||
expect(result[0].settings).toEqual({ correctValue: 42 });
|
||||
expect(result[1].options.find((o) => o.isCorrect)?.id).toBe(80);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildWidgetGoogleFontsStylesheetHref,
|
||||
collectWidgetGoogleFontFamilies,
|
||||
getDefaultWidgetConfig,
|
||||
getWidgetThemeSerifStack,
|
||||
WIDGET_THEME_PRESET_VALUES
|
||||
} from '@cio/utils/validation/widget';
|
||||
|
||||
describe('Widget Google Fonts', () => {
|
||||
it('loads theme-specific families for each preset', () => {
|
||||
for (const theme of WIDGET_THEME_PRESET_VALUES) {
|
||||
const config = { ...getDefaultWidgetConfig(), themePreset: theme };
|
||||
const families = collectWidgetGoogleFontFamilies(config);
|
||||
expect(families.length).toBeGreaterThan(0);
|
||||
const href = buildWidgetGoogleFontsStylesheetHref(config);
|
||||
expect(href.startsWith('https://fonts.googleapis.com/css2?')).toBe(true);
|
||||
expect(href).toContain('display=swap');
|
||||
}
|
||||
});
|
||||
|
||||
it('includes IBM Plex families for graphite', () => {
|
||||
const config = { ...getDefaultWidgetConfig(), themePreset: 'graphite' as const };
|
||||
const families = collectWidgetGoogleFontFamilies(config);
|
||||
expect(families).toContain('IBM Plex Sans');
|
||||
expect(families).toContain('IBM Plex Serif');
|
||||
});
|
||||
|
||||
it('merges quoted custom Google fonts from typography.fontFamily', () => {
|
||||
const config = {
|
||||
...getDefaultWidgetConfig(),
|
||||
themePreset: 'classroomio' as const,
|
||||
typography: {
|
||||
...getDefaultWidgetConfig().typography,
|
||||
fontFamily: "'Manrope', ui-sans-serif, sans-serif"
|
||||
}
|
||||
};
|
||||
expect(collectWidgetGoogleFontFamilies(config)).toContain('Manrope');
|
||||
});
|
||||
|
||||
it('exposes serif stacks per theme', () => {
|
||||
expect(getWidgetThemeSerifStack('classroomio')).toContain('Lora');
|
||||
expect(getWidgetThemeSerifStack('spruce')).toContain('Fraunces');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
/** Mirrors the key generation logic from widget-payload.ts for isolated testing. */
|
||||
function encodeBase58(bytes: Uint8Array): string {
|
||||
let value = 0n;
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8n) + BigInt(byte);
|
||||
}
|
||||
|
||||
if (value === 0n) {
|
||||
return BASE58_ALPHABET[0];
|
||||
}
|
||||
|
||||
let encoded = '';
|
||||
while (value > 0n) {
|
||||
const remainder = Number(value % 58n);
|
||||
encoded = BASE58_ALPHABET[remainder] + encoded;
|
||||
value /= 58n;
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function generateWidgetPublicKey(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function getWidgetEmbedCode(publicKey: string): string {
|
||||
const scriptUrl = 'https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js';
|
||||
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
|
||||
}
|
||||
|
||||
describe('generateWidgetPublicKey', () => {
|
||||
it('should start with wgt_ prefix', () => {
|
||||
const key = generateWidgetPublicKey();
|
||||
expect(key.startsWith('wgt_')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have expected length', () => {
|
||||
const key = generateWidgetPublicKey();
|
||||
expect(key.length).toBe(20); // 'wgt_' + 16 chars
|
||||
});
|
||||
|
||||
it('should generate unique keys', () => {
|
||||
const keys = new Set(Array.from({ length: 50 }, () => generateWidgetPublicKey()));
|
||||
expect(keys.size).toBe(50);
|
||||
});
|
||||
|
||||
it('should only contain base58 characters after prefix', () => {
|
||||
const key = generateWidgetPublicKey();
|
||||
const suffix = key.slice(4);
|
||||
const base58Regex = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/;
|
||||
expect(base58Regex.test(suffix)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeBase58', () => {
|
||||
it('should encode zero bytes to 1', () => {
|
||||
expect(encodeBase58(new Uint8Array([0]))).toBe('1');
|
||||
});
|
||||
|
||||
it('should produce deterministic output', () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
||||
const resultA = encodeBase58(bytes);
|
||||
const resultB = encodeBase58(bytes);
|
||||
expect(resultA).toBe(resultB);
|
||||
});
|
||||
|
||||
it('should not contain ambiguous characters (0, O, I, l)', () => {
|
||||
const encoded = BASE58_ALPHABET;
|
||||
expect(encoded).not.toContain('0');
|
||||
expect(encoded).not.toContain('O');
|
||||
expect(encoded).not.toContain('I');
|
||||
expect(encoded).not.toContain('l');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWidgetEmbedCode', () => {
|
||||
it('should contain the public key in data attribute', () => {
|
||||
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
|
||||
expect(embedCode).toContain('data-widget-key="wgt_testkey123456"');
|
||||
});
|
||||
|
||||
it('should contain the widget type data attribute', () => {
|
||||
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
|
||||
expect(embedCode).toContain('data-cio-widget="course-widget"');
|
||||
});
|
||||
|
||||
it('should contain an async module script tag', () => {
|
||||
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
|
||||
expect(embedCode).toContain('<script async type="module"');
|
||||
});
|
||||
|
||||
it('should reference the CDN script URL', () => {
|
||||
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
|
||||
expect(embedCode).toContain('course-widget.js');
|
||||
});
|
||||
|
||||
it('should properly escape special characters in public key', () => {
|
||||
// Public keys are base58, so no special HTML chars, but verify structure
|
||||
const embedCode = getWidgetEmbedCode('wgt_abc123def456gh');
|
||||
expect(embedCode).toMatch(/^<div[^>]+><\/div>\n<script[^>]+><\/script>$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ZCreateWidget,
|
||||
ZUpdateWidget,
|
||||
ZWidgetConfig,
|
||||
ZWidgetPayload,
|
||||
ZWidgetPublicKeyParams,
|
||||
getDefaultWidgetConfig,
|
||||
getLayoutCourseLimit,
|
||||
normalizeWidgetConfig,
|
||||
resolveWidgetPlanGatedFields,
|
||||
isPaidWidgetPlan,
|
||||
WIDGET_LAYOUT_TYPE_VALUES,
|
||||
WIDGET_THEME_PRESET_VALUES
|
||||
} from '@cio/utils/validation/widget';
|
||||
import type { TWidgetConfig } from '@cio/utils/validation/widget';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
|
||||
const SAMPLE_UUID = '11111111-1111-4111-8111-111111111111';
|
||||
const ANOTHER_UUID = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
describe('Widget Validation Schemas', () => {
|
||||
describe('ZCreateWidget', () => {
|
||||
it('should parse valid create input', () => {
|
||||
const result = ZCreateWidget.safeParse({
|
||||
name: 'My Widget',
|
||||
layoutType: 'card_grid',
|
||||
selectionMode: 'manual',
|
||||
selectedCourseIds: []
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('My Widget');
|
||||
expect(result.data.layoutType).toBe('card_grid');
|
||||
}
|
||||
});
|
||||
|
||||
it('should apply defaults for optional fields', () => {
|
||||
const result = ZCreateWidget.safeParse({ name: 'Test' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.layoutType).toBe('card_grid');
|
||||
expect(result.data.selectionMode).toBe('manual');
|
||||
expect(result.data.selectedCourseIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty name', () => {
|
||||
const result = ZCreateWidget.safeParse({ name: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject name longer than 120 characters', () => {
|
||||
const result = ZCreateWidget.safeParse({ name: 'a'.repeat(121) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject invalid layout type', () => {
|
||||
const result = ZCreateWidget.safeParse({ name: 'Test', layoutType: 'invalid' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept all valid layout types', () => {
|
||||
for (const layoutType of WIDGET_LAYOUT_TYPE_VALUES) {
|
||||
const baseInput: Record<string, unknown> = {
|
||||
name: 'Test',
|
||||
layoutType,
|
||||
selectionMode: 'published'
|
||||
};
|
||||
|
||||
if (layoutType === 'primary_course') {
|
||||
baseInput.config = {
|
||||
layoutOptions: { primaryCourse: { featuredCourseId: SAMPLE_UUID } }
|
||||
};
|
||||
} else if (layoutType === 'editorial_spotlight') {
|
||||
baseInput.config = {
|
||||
layoutOptions: { editorialSpotlight: { mainCourseId: SAMPLE_UUID } }
|
||||
};
|
||||
} else if (layoutType === 'category_shelf') {
|
||||
baseInput.config = {
|
||||
layoutOptions: { categoryShelf: { categoryTagIds: [SAMPLE_UUID, ANOTHER_UUID] } }
|
||||
};
|
||||
}
|
||||
|
||||
const result = ZCreateWidget.safeParse(baseInput);
|
||||
expect(result.success, `${layoutType} should be valid: ${JSON.stringify(result.error?.issues)}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject primary_course layout without featuredCourseId', () => {
|
||||
const result = ZCreateWidget.safeParse({ name: 'Test', layoutType: 'primary_course' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject editorial_spotlight layout without mainCourseId', () => {
|
||||
const result = ZCreateWidget.safeParse({ name: 'Test', layoutType: 'editorial_spotlight' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept category_shelf with manual selection and empty categoryTagIds', () => {
|
||||
const result = ZCreateWidget.safeParse({
|
||||
name: 'Test',
|
||||
layoutType: 'category_shelf',
|
||||
selectionMode: 'manual',
|
||||
selectedCourseIds: [SAMPLE_UUID]
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject category_shelf with published selection and empty categoryTagIds', () => {
|
||||
const result = ZCreateWidget.safeParse({
|
||||
name: 'Test',
|
||||
layoutType: 'category_shelf',
|
||||
selectionMode: 'published'
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZUpdateWidget', () => {
|
||||
it('should parse valid partial update', () => {
|
||||
const result = ZUpdateWidget.safeParse({ name: 'Updated Name' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept empty object', () => {
|
||||
const result = ZUpdateWidget.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty name string', () => {
|
||||
const result = ZUpdateWidget.safeParse({ name: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZWidgetConfig', () => {
|
||||
it('should parse empty object with defaults', () => {
|
||||
const result = ZWidgetConfig.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.themePreset).toBe('classroomio');
|
||||
expect(result.data.sortBy).toBe('manual');
|
||||
expect(result.data.colors.primaryColor).toBe('#0f172a');
|
||||
expect(result.data.layoutOptions.cardGrid.maxCount).toBe(9);
|
||||
expect(result.data.layoutOptions.tagFilter.maxCount).toBe(12);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject custom CSS exceeding 5KB', () => {
|
||||
const result = ZWidgetConfig.safeParse({
|
||||
advanced: { customCss: 'a'.repeat(5001) }
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept custom CSS within 5KB', () => {
|
||||
const result = ZWidgetConfig.safeParse({
|
||||
advanced: { customCss: '.widget { color: red; }' }
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid theme preset', () => {
|
||||
const result = ZWidgetConfig.safeParse({ themePreset: 'invalid' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject font scale below 0.8', () => {
|
||||
const result = ZWidgetConfig.safeParse({
|
||||
typography: { fontFamily: 'sans-serif', fontSizeScale: 0.5 }
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject font scale above 1.4', () => {
|
||||
const result = ZWidgetConfig.safeParse({
|
||||
typography: { fontFamily: 'sans-serif', fontSizeScale: 2.0 }
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZWidgetPublicKeyParams', () => {
|
||||
it('should accept valid public key', () => {
|
||||
const result = ZWidgetPublicKeyParams.safeParse({ publicKey: 'wgt_3J98t1WpEZ73CN' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject short public key', () => {
|
||||
const result = ZWidgetPublicKeyParams.safeParse({ publicKey: 'wgt' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty public key', () => {
|
||||
const result = ZWidgetPublicKeyParams.safeParse({ publicKey: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultWidgetConfig', () => {
|
||||
it('should return valid config', () => {
|
||||
const config = getDefaultWidgetConfig();
|
||||
const result = ZWidgetConfig.safeParse(config);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return consistent defaults', () => {
|
||||
const configA = getDefaultWidgetConfig();
|
||||
const configB = getDefaultWidgetConfig();
|
||||
expect(configA).toEqual(configB);
|
||||
});
|
||||
|
||||
it('should expose layoutOptions for every layout type', () => {
|
||||
const config = getDefaultWidgetConfig();
|
||||
expect(Object.keys(config.layoutOptions)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'cardGrid',
|
||||
'tagFilter',
|
||||
'carousel',
|
||||
'primaryCourse',
|
||||
'compactList',
|
||||
'editorialSpotlight',
|
||||
'categoryShelf'
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLayoutCourseLimit', () => {
|
||||
it('should respect per-layout maxCount values', () => {
|
||||
const config = getDefaultWidgetConfig();
|
||||
expect(getLayoutCourseLimit('card_grid', config.layoutOptions)).toBe(9);
|
||||
expect(getLayoutCourseLimit('tag_filter', config.layoutOptions)).toBe(12);
|
||||
expect(getLayoutCourseLimit('carousel', config.layoutOptions)).toBe(6);
|
||||
expect(getLayoutCourseLimit('compact_list', config.layoutOptions)).toBe(5);
|
||||
});
|
||||
|
||||
it('should sum primary course slots', () => {
|
||||
const config = getDefaultWidgetConfig();
|
||||
config.layoutOptions.primaryCourse.secondaryMaxCount = 3;
|
||||
expect(getLayoutCourseLimit('primary_course', config.layoutOptions)).toBe(4);
|
||||
});
|
||||
|
||||
it('should sum editorial spotlight slots', () => {
|
||||
const config = getDefaultWidgetConfig();
|
||||
config.layoutOptions.editorialSpotlight.secondaryMaxCount = 4;
|
||||
expect(getLayoutCourseLimit('editorial_spotlight', config.layoutOptions)).toBe(5);
|
||||
});
|
||||
|
||||
it('should compute category shelf via tags × maxPerCategory', () => {
|
||||
const config = getDefaultWidgetConfig();
|
||||
config.layoutOptions.categoryShelf.categoryTagIds = [SAMPLE_UUID, ANOTHER_UUID];
|
||||
config.layoutOptions.categoryShelf.maxPerCategory = 6;
|
||||
expect(getLayoutCourseLimit('category_shelf', config.layoutOptions)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPaidWidgetPlan', () => {
|
||||
it('should return false for BASIC plan', () => {
|
||||
expect(isPaidWidgetPlan(PLAN.BASIC)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for EARLY_ADOPTER plan', () => {
|
||||
expect(isPaidWidgetPlan(PLAN.EARLY_ADOPTER)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for ENTERPRISE plan', () => {
|
||||
expect(isPaidWidgetPlan(PLAN.ENTERPRISE)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined', () => {
|
||||
expect(isPaidWidgetPlan(null)).toBe(false);
|
||||
expect(isPaidWidgetPlan(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWidgetPlanGatedFields', () => {
|
||||
it('should restrict free plan to one theme', () => {
|
||||
const fields = resolveWidgetPlanGatedFields(PLAN.BASIC);
|
||||
expect(fields.isPaidPlan).toBe(false);
|
||||
expect(fields.availableThemes).toEqual(['classroomio']);
|
||||
expect(fields.canUseCustomColors).toBe(false);
|
||||
expect(fields.canUseCustomCss).toBe(false);
|
||||
expect(fields.isBrandingForced).toBe(true);
|
||||
});
|
||||
|
||||
it('should unlock all themes for paid plan', () => {
|
||||
const fields = resolveWidgetPlanGatedFields(PLAN.EARLY_ADOPTER);
|
||||
expect(fields.isPaidPlan).toBe(true);
|
||||
expect(fields.availableThemes).toEqual([...WIDGET_THEME_PRESET_VALUES]);
|
||||
expect(fields.canUseCustomColors).toBe(true);
|
||||
expect(fields.canUseCustomCss).toBe(true);
|
||||
expect(fields.isBrandingForced).toBe(false);
|
||||
});
|
||||
|
||||
it('should fallback invalid theme to classroomio on free plan', () => {
|
||||
const fields = resolveWidgetPlanGatedFields(PLAN.BASIC, 'graphite');
|
||||
expect(fields.selectedTheme).toBe('classroomio');
|
||||
});
|
||||
|
||||
it('should allow selected theme on paid plan', () => {
|
||||
const fields = resolveWidgetPlanGatedFields(PLAN.EARLY_ADOPTER, 'spruce');
|
||||
expect(fields.selectedTheme).toBe('spruce');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeWidgetConfig', () => {
|
||||
it('should enforce branding for free plan', () => {
|
||||
const config = normalizeWidgetConfig({ branding: { showPoweredBy: false } }, PLAN.BASIC);
|
||||
expect(config.branding.showPoweredBy).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow branding toggle for paid plan', () => {
|
||||
const config = normalizeWidgetConfig({ branding: { showPoweredBy: false } }, PLAN.EARLY_ADOPTER);
|
||||
expect(config.branding.showPoweredBy).toBe(false);
|
||||
});
|
||||
|
||||
it('should strip custom CSS for free plan', () => {
|
||||
const config = normalizeWidgetConfig({ advanced: { customCss: '.foo { color: red; }' } }, PLAN.BASIC);
|
||||
expect(config.advanced.customCss).toBe('');
|
||||
});
|
||||
|
||||
it('should keep custom CSS for paid plan', () => {
|
||||
const config = normalizeWidgetConfig({ advanced: { customCss: '.foo { color: red; }' } }, PLAN.EARLY_ADOPTER);
|
||||
expect(config.advanced.customCss).toBe('.foo { color: red; }');
|
||||
});
|
||||
|
||||
it('should reset colors to defaults for free plan', () => {
|
||||
const config = normalizeWidgetConfig(
|
||||
{
|
||||
colors: {
|
||||
primaryColor: '#ff0000',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#111827',
|
||||
badgeColor: '#e2e8f0',
|
||||
borderColor: '#e5e7eb',
|
||||
highlightColor: '#dbeafe'
|
||||
}
|
||||
} as Partial<TWidgetConfig>,
|
||||
PLAN.BASIC
|
||||
);
|
||||
const defaultConfig = getDefaultWidgetConfig();
|
||||
expect(config.colors).toEqual(defaultConfig.colors);
|
||||
});
|
||||
|
||||
it('should allow custom colors for paid plan', () => {
|
||||
const config = normalizeWidgetConfig(
|
||||
{
|
||||
colors: {
|
||||
primaryColor: '#ff0000',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#111827',
|
||||
badgeColor: '#e2e8f0',
|
||||
borderColor: '#e5e7eb',
|
||||
highlightColor: '#dbeafe'
|
||||
}
|
||||
} as Partial<TWidgetConfig>,
|
||||
PLAN.EARLY_ADOPTER
|
||||
);
|
||||
expect(config.colors.primaryColor).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('should handle null/undefined config input', () => {
|
||||
const config = normalizeWidgetConfig(null);
|
||||
expect(config).toBeDefined();
|
||||
expect(config.themePreset).toBe('classroomio');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user