Skip to main content

headless_lms_server/programs/seed/builder/
course.rs

1use anyhow::{Context, Result};
2use headless_lms_base::config::ApplicationConfiguration;
3use sqlx::{Connection, PgConnection};
4use tracing::log::warn;
5use uuid::Uuid;
6
7use headless_lms_models::{
8    PKeyPolicy, certificate_configuration_to_requirements,
9    certificate_configurations::{self, DatabaseCertificateConfiguration},
10    chatbot_configurations::{self, NewChatbotConf},
11    course_instances::{self, CourseInstance, NewCourseInstance},
12    course_language_groups,
13    course_modules::CourseModule,
14    courses::{self, Course, NewCourse},
15    file_uploads, glossary,
16    library::content_management,
17    pages::{self, NewCoursePage, NewPage},
18    roles::{self, RoleDomain, UserRole},
19};
20use headless_lms_utils::document_schema_processor::GutenbergBlock;
21
22use crate::programs::seed::{
23    builder::{context::SeedContext, module::ModuleBuilder},
24    seed_helpers::get_seed_spec_fetcher,
25};
26
27/// Additional course instance configuration
28#[derive(Debug, Clone)]
29pub struct CourseInstanceConfig {
30    pub name: Option<String>,
31    pub description: Option<String>,
32    pub support_email: Option<String>,
33    pub teacher_in_charge_name: String,
34    pub teacher_in_charge_email: String,
35    pub opening_time: Option<chrono::DateTime<chrono::Utc>>,
36    pub closing_time: Option<chrono::DateTime<chrono::Utc>>,
37    pub instance_id: Option<Uuid>,
38}
39
40/// Generic page configuration for top-level course pages
41#[derive(Debug, Clone)]
42pub struct PageConfig {
43    pub url: String,
44    pub title: String,
45    pub page_number: i32,
46    pub is_hidden: bool,
47    pub content: Option<Vec<headless_lms_utils::document_schema_processor::GutenbergBlock>>,
48}
49
50/// Glossary entry
51#[derive(Debug, Clone)]
52pub struct GlossaryEntry {
53    pub acronym: String,
54    pub definition: String,
55}
56
57/// Certificate configuration
58#[derive(Debug, Clone)]
59pub struct CertificateConfig {
60    pub background_svg_path: String,
61    pub certificate_id: Option<Uuid>,
62}
63
64/// Builder for courses with modules, chapters, pages, and exercises.
65#[derive(Debug, Clone)]
66pub struct CourseBuilder {
67    pub name: String,
68    pub slug: String,
69    pub language_code: String,
70    pub can_add_chatbot: bool,
71    pub description: String,
72    pub default_instance_name: &'static [u8],
73    pub modules: Vec<ModuleBuilder>,
74    pub extra_roles: Vec<(Uuid, UserRole)>,
75    pub course_id: Option<Uuid>,
76    pub instances: Vec<CourseInstanceConfig>,
77    pub chatbot_configs: Vec<NewChatbotConf>,
78    pub pages: Vec<PageConfig>,
79    pub glossary_entries: Vec<GlossaryEntry>,
80    pub certificate_config: Option<CertificateConfig>,
81    pub front_page_content: Option<Vec<GutenbergBlock>>,
82    pub chapter_locking_enabled: bool,
83    pub flagged_answers_skip_manual_review_and_allow_retry: bool,
84    pub flagged_answers_threshold: Option<i32>,
85}
86
87impl CourseBuilder {
88    pub fn new(name: impl Into<String>, slug: impl Into<String>) -> Self {
89        Self {
90            name: name.into(),
91            slug: slug.into(),
92            language_code: "en".into(),
93            can_add_chatbot: false,
94            description: "Sample course.".into(),
95            default_instance_name: b"default-instance",
96            modules: vec![],
97            extra_roles: vec![],
98            course_id: None,
99            instances: vec![],
100            chatbot_configs: vec![],
101            pages: vec![],
102            glossary_entries: vec![],
103            certificate_config: None,
104            front_page_content: None,
105            chapter_locking_enabled: false,
106            flagged_answers_skip_manual_review_and_allow_retry: false,
107            flagged_answers_threshold: None,
108        }
109    }
110
111    /// Enables skipping manual review for flagged answers and allowing retry.
112    pub fn flagged_answers_skip_manual_review_and_allow_retry(mut self, v: bool) -> Self {
113        self.flagged_answers_skip_manual_review_and_allow_retry = v;
114        self
115    }
116
117    /// Sets the number of spam reports needed to trigger reset/manual review.
118    pub fn flagged_answers_threshold(mut self, v: Option<i32>) -> Self {
119        self.flagged_answers_threshold = v;
120        self
121    }
122
123    pub fn chatbot(mut self, v: bool) -> Self {
124        self.can_add_chatbot = v;
125        self
126    }
127    pub fn chapter_locking_enabled(mut self, v: bool) -> Self {
128        self.chapter_locking_enabled = v;
129        self
130    }
131    pub fn desc(mut self, d: impl Into<String>) -> Self {
132        self.description = d.into();
133        self
134    }
135    pub fn language(mut self, lc: impl Into<String>) -> Self {
136        self.language_code = lc.into();
137        self
138    }
139    pub fn module(mut self, m: ModuleBuilder) -> Self {
140        self.modules.push(m);
141        self
142    }
143    pub fn modules<I: IntoIterator<Item = ModuleBuilder>>(mut self, it: I) -> Self {
144        self.modules.extend(it);
145        self
146    }
147    pub fn role(mut self, user_id: Uuid, role: UserRole) -> Self {
148        self.extra_roles.push((user_id, role));
149        self
150    }
151    pub fn course_id(mut self, id: Uuid) -> Self {
152        self.course_id = Some(id);
153        self
154    }
155
156    pub fn instance(mut self, config: CourseInstanceConfig) -> Self {
157        self.instances.push(config);
158        self
159    }
160
161    pub fn chatbot_config(mut self, config: NewChatbotConf) -> Self {
162        if !self.can_add_chatbot {
163            warn!("Can't add chatbot to this course!!!!");
164            return self;
165        }
166        self.chatbot_configs.push(config);
167        self
168    }
169
170    /// Add a top-level page to the course
171    pub fn top_level_page(
172        mut self,
173        url: impl Into<String>,
174        title: impl Into<String>,
175        page_number: i32,
176        is_hidden: bool,
177        content: Option<Vec<headless_lms_utils::document_schema_processor::GutenbergBlock>>,
178    ) -> Self {
179        self.pages.push(PageConfig {
180            url: url.into(),
181            title: title.into(),
182            page_number,
183            is_hidden,
184            content,
185        });
186        self
187    }
188
189    pub fn glossary_entry(
190        mut self,
191        acronym: impl Into<String>,
192        definition: impl Into<String>,
193    ) -> Self {
194        self.glossary_entries.push(GlossaryEntry {
195            acronym: acronym.into(),
196            definition: definition.into(),
197        });
198        self
199    }
200
201    pub fn certificate_config(
202        mut self,
203        background_svg_path: impl Into<String>,
204        certificate_id: Option<Uuid>,
205    ) -> Self {
206        self.certificate_config = Some(CertificateConfig {
207            background_svg_path: background_svg_path.into(),
208            certificate_id,
209        });
210        self
211    }
212
213    /// Set custom content for the course front page. If not set, default content will be used.
214    pub fn front_page_content(mut self, content: Vec<GutenbergBlock>) -> Self {
215        self.front_page_content = Some(content);
216        self
217    }
218
219    /// Seeds the course and all nested content. Returns `(course, default_instance, last_module)`.
220    pub async fn seed(
221        self,
222        conn: &mut PgConnection,
223        app_config: &ApplicationConfiguration,
224        cx: &SeedContext,
225    ) -> Result<(Course, CourseInstance, CourseModule)> {
226        let course_id = self.course_id.unwrap_or(cx.base_course_ns);
227
228        let new_course = NewCourse {
229            name: self.name.clone(),
230            organization_id: cx.org,
231            slug: self.slug.clone(),
232            language_code: self.language_code.clone(),
233            teacher_in_charge_name: "admin".into(),
234            teacher_in_charge_email: "admin@example.com".into(),
235            description: self.description.clone(),
236            is_draft: false,
237            is_test_mode: false,
238            is_unlisted: false,
239            copy_user_permissions: false,
240            is_joinable_by_code_only: false,
241            join_code: None,
242            ask_marketing_consent: false,
243            flagged_answers_threshold: Some(self.flagged_answers_threshold.unwrap_or(3)),
244            can_add_chatbot: self.can_add_chatbot,
245        };
246
247        // Create course manually without default module
248        let mut tx = conn.begin().await.context("starting transaction")?;
249
250        let course_language_group_id = course_language_groups::insert(
251            &mut tx,
252            headless_lms_models::PKeyPolicy::Generate,
253            &new_course.slug,
254        )
255        .await?;
256
257        let course_id = courses::insert(
258            &mut tx,
259            app_config,
260            headless_lms_models::PKeyPolicy::Fixed(course_id),
261            course_language_group_id,
262            &new_course,
263        )
264        .await
265        .context("inserting course")?;
266
267        let course = courses::get_course(&mut tx, course_id)
268            .await
269            .context("getting course")?;
270
271        for chatbot_conf in self.chatbot_configs {
272            let chatbotconf_id = chatbot_conf.chatbotconf_id.unwrap_or_else(Uuid::new_v4);
273            chatbot_configurations::insert(
274                &mut tx,
275                PKeyPolicy::Fixed(chatbotconf_id),
276                NewChatbotConf {
277                    course_id: Some(course_id),
278                    ..chatbot_conf
279                },
280            )
281            .await
282            .context("inserting chatbot configuration for course")?;
283        }
284
285        let mut last_module = None;
286
287        for (i, m) in self.modules.into_iter().enumerate() {
288            last_module = Some(m.seed(&mut tx, cx, course.id, i as i32).await?);
289        }
290
291        for (user_id, role) in self.extra_roles {
292            roles::insert(&mut tx, user_id, role, RoleDomain::Course(course.id))
293                .await
294                .context("inserting course role")?;
295        }
296
297        // Create course instances
298        let mut default_instance = None;
299        for instance_config in self.instances {
300            let instance_id = instance_config.instance_id.unwrap_or_else(Uuid::new_v4);
301            let instance = course_instances::insert(
302                &mut tx,
303                PKeyPolicy::Fixed(instance_id),
304                NewCourseInstance {
305                    course_id: course.id,
306                    name: instance_config.name.as_deref(),
307                    description: instance_config.description.as_deref(),
308                    support_email: instance_config.support_email.as_deref(),
309                    teacher_in_charge_name: &instance_config.teacher_in_charge_name,
310                    teacher_in_charge_email: &instance_config.teacher_in_charge_email,
311                    opening_time: instance_config.opening_time,
312                    closing_time: instance_config.closing_time,
313                },
314            )
315            .await
316            .context("inserting course instance")?;
317
318            // Use the first instance as the default instance for return value
319            if default_instance.is_none() {
320                default_instance = Some(instance);
321            }
322        }
323
324        let default_instance =
325            default_instance.expect("At least one course instance must be provided");
326
327        let front_page_blocks = self
328            .front_page_content
329            .unwrap_or_else(content_management::default_course_front_page_content);
330
331        let course_front_page = NewPage {
332            chapter_id: None,
333            content: front_page_blocks,
334            course_id: Some(course.id),
335            exam_id: None,
336            front_page_of_chapter_id: None,
337            title: course.name.clone(),
338            url_path: String::from("/"),
339            exercises: vec![],
340            exercise_slides: vec![],
341            exercise_tasks: vec![],
342            content_search_language: None,
343            hidden: false,
344        };
345
346        pages::insert_page(
347            &mut tx,
348            course_front_page,
349            cx.teacher,
350            get_seed_spec_fetcher(),
351            crate::domain::models_requests::fetch_service_info,
352        )
353        .await
354        .context("inserting course front page")?;
355
356        // Create top-level pages
357        for page_config in self.pages {
358            let mut course_page = NewCoursePage::new(
359                course.id,
360                page_config.page_number,
361                &page_config.url,
362                &page_config.title,
363            );
364
365            if page_config.is_hidden {
366                course_page = course_page.set_hidden(true);
367            }
368
369            if let Some(content) = page_config.content {
370                course_page = course_page.set_content(content);
371            }
372
373            pages::insert_course_page(&mut tx, &course_page, cx.teacher)
374                .await
375                .context("inserting course page")?;
376        }
377
378        // Add glossary entries
379        for glossary_entry in self.glossary_entries {
380            glossary::insert(
381                &mut tx,
382                &glossary_entry.acronym,
383                &glossary_entry.definition,
384                course.id,
385            )
386            .await
387            .context("inserting glossary entry")?;
388        }
389
390        // Create certificate configuration
391        if let Some(cert_config) = self.certificate_config {
392            let background_svg_file_upload_id = file_uploads::insert(
393                &mut tx,
394                &format!(
395                    "background-{}.svg",
396                    last_module
397                        .as_ref()
398                        .expect("At least one module must be provided")
399                        .id
400                ),
401                &cert_config.background_svg_path,
402                "image/svg+xml",
403                None,
404                None,
405            )
406            .await
407            .context("inserting certificate background file")?;
408
409            let certificate_id = cert_config
410                .certificate_id
411                .unwrap_or_else(|| cx.v5(b"886d3e22-5007-4371-94d7-e0ad93a2391c"));
412            let configuration = DatabaseCertificateConfiguration {
413                id: certificate_id,
414                certificate_owner_name_y_pos: None,
415                certificate_owner_name_x_pos: None,
416                certificate_owner_name_font_size: None,
417                certificate_owner_name_text_color: None,
418                certificate_owner_name_text_anchor: None,
419                certificate_validate_url_y_pos: None,
420                certificate_validate_url_x_pos: None,
421                certificate_validate_url_font_size: None,
422                certificate_validate_url_text_color: None,
423                certificate_validate_url_text_anchor: None,
424                certificate_date_y_pos: None,
425                certificate_date_x_pos: None,
426                certificate_date_font_size: None,
427                certificate_date_text_color: None,
428                certificate_date_text_anchor: None,
429                certificate_locale: None,
430                paper_size: None,
431                background_svg_path: cert_config.background_svg_path,
432                background_svg_file_upload_id,
433                overlay_svg_path: None,
434                overlay_svg_file_upload_id: None,
435                render_certificate_grade: false,
436                certificate_grade_y_pos: None,
437                certificate_grade_x_pos: None,
438                certificate_grade_font_size: None,
439                certificate_grade_text_color: None,
440                certificate_grade_text_anchor: None,
441            };
442            let database_configuration =
443                certificate_configurations::insert(&mut tx, &configuration)
444                    .await
445                    .context("inserting certificate configuration")?;
446            certificate_configuration_to_requirements::insert(
447                &mut tx,
448                database_configuration.id,
449                Some(
450                    last_module
451                        .as_ref()
452                        .expect("At least one module must be provided")
453                        .id,
454                ),
455            )
456            .await
457            .context("linking certificate configuration to requirements")?;
458        }
459        tx.commit().await.context("committing transaction")?;
460
461        // Seeded courses are completed in seconds by system tests, which would flag every seeded
462        // user and break the suite, so disable cheater detection for builder-created courses.
463        courses::set_cheater_detection_enabled(conn, course.id, false)
464            .await
465            .context("disabling cheater detection")?;
466
467        if self.chapter_locking_enabled || self.flagged_answers_skip_manual_review_and_allow_retry {
468            use headless_lms_models::courses::CourseUpdate;
469            let course_update = CourseUpdate {
470                flagged_answers_threshold: self.flagged_answers_threshold.unwrap_or(3),
471                flagged_answers_skip_manual_review_and_allow_retry: self
472                    .flagged_answers_skip_manual_review_and_allow_retry,
473                chapter_locking_enabled: self.chapter_locking_enabled,
474                ..CourseUpdate::from(&course)
475            };
476            let updated_course = courses::update_course(
477                conn,
478                app_config,
479                course.id,
480                course_update,
481                cx.teacher,
482                false,
483            )
484            .await
485            .context("updating course")?;
486            return Ok((
487                updated_course,
488                default_instance,
489                last_module.expect("At least one module must be provided"),
490            ));
491        }
492
493        Ok((
494            course,
495            default_instance,
496            last_module.expect("At least one module must be provided"),
497        ))
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use crate::programs::seed::builder::module::ModuleBuilder;
504
505    use super::*;
506    use headless_lms_models::roles::UserRole;
507
508    #[test]
509    fn course_builder_new() {
510        let course = CourseBuilder::new("Test Course", "test-course");
511
512        assert_eq!(course.name, "Test Course");
513        assert_eq!(course.slug, "test-course");
514        assert_eq!(course.language_code, "en");
515        assert!(!course.can_add_chatbot);
516        assert_eq!(course.description, "Sample course.");
517        assert_eq!(course.default_instance_name, b"default-instance");
518        assert!(course.modules.is_empty());
519        assert!(course.extra_roles.is_empty());
520        assert!(course.course_id.is_none());
521    }
522
523    #[test]
524    fn course_builder_string_conversion() {
525        let course1 = CourseBuilder::new("String literal", "string-slug");
526        let course2 = CourseBuilder::new(String::from("Owned string"), String::from("owned-slug"));
527
528        assert_eq!(course1.name, "String literal");
529        assert_eq!(course1.slug, "string-slug");
530        assert_eq!(course2.name, "Owned string");
531        assert_eq!(course2.slug, "owned-slug");
532    }
533
534    #[test]
535    fn course_builder_chatbot() {
536        let course = CourseBuilder::new("Test Course", "test-course").chatbot(true);
537        assert!(course.can_add_chatbot);
538
539        let course_disabled = CourseBuilder::new("Test Course", "test-course").chatbot(false);
540        assert!(!course_disabled.can_add_chatbot);
541    }
542
543    #[test]
544    fn course_builder_desc() {
545        let course =
546            CourseBuilder::new("Test Course", "test-course").desc("A comprehensive test course");
547
548        assert_eq!(course.description, "A comprehensive test course");
549
550        let desc_string = String::from("Another description");
551        let course2 = CourseBuilder::new("Test Course", "test-course").desc(desc_string);
552        assert_eq!(course2.description, "Another description");
553    }
554
555    #[test]
556    fn course_builder_language() {
557        let course = CourseBuilder::new("Test Course", "test-course").language("fi-FI");
558
559        assert_eq!(course.language_code, "fi-FI");
560
561        let lang_string = String::from("sv-SE");
562        let course2 = CourseBuilder::new("Test Course", "test-course").language(lang_string);
563        assert_eq!(course2.language_code, "sv-SE");
564    }
565
566    #[test]
567    fn course_builder_module() {
568        let module = ModuleBuilder::new().order(0);
569        let course = CourseBuilder::new("Test Course", "test-course").module(module);
570
571        assert_eq!(course.modules.len(), 1);
572        assert_eq!(course.modules[0].order, Some(0));
573    }
574
575    #[test]
576    fn course_builder_multiple_modules() {
577        let module1 = ModuleBuilder::new().order(0);
578        let module2 = ModuleBuilder::new().order(1);
579        let course = CourseBuilder::new("Test Course", "test-course")
580            .module(module1)
581            .module(module2);
582
583        assert_eq!(course.modules.len(), 2);
584        assert_eq!(course.modules[0].order, Some(0));
585        assert_eq!(course.modules[1].order, Some(1));
586    }
587
588    #[test]
589    fn course_builder_role() {
590        let user_id = Uuid::new_v4();
591        let course =
592            CourseBuilder::new("Test Course", "test-course").role(user_id, UserRole::Teacher);
593
594        assert_eq!(course.extra_roles.len(), 1);
595        assert_eq!(course.extra_roles[0].0, user_id);
596        assert_eq!(course.extra_roles[0].1, UserRole::Teacher);
597    }
598
599    #[test]
600    fn course_builder_multiple_roles() {
601        let user1_id = Uuid::new_v4();
602        let user2_id = Uuid::new_v4();
603        let course = CourseBuilder::new("Test Course", "test-course")
604            .role(user1_id, UserRole::Teacher)
605            .role(user2_id, UserRole::MaterialViewer);
606
607        assert_eq!(course.extra_roles.len(), 2);
608        assert_eq!(course.extra_roles[0].0, user1_id);
609        assert_eq!(course.extra_roles[0].1, UserRole::Teacher);
610        assert_eq!(course.extra_roles[1].0, user2_id);
611        assert_eq!(course.extra_roles[1].1, UserRole::MaterialViewer);
612    }
613
614    #[test]
615    fn course_builder_course_id() {
616        let course_id = Uuid::new_v4();
617        let course = CourseBuilder::new("Test Course", "test-course").course_id(course_id);
618
619        assert_eq!(course.course_id, Some(course_id));
620    }
621
622    #[test]
623    fn course_builder_fluent_interface() {
624        let user_id = Uuid::new_v4();
625        let course_id = Uuid::new_v4();
626        let module = ModuleBuilder::new().order(0);
627
628        let course = CourseBuilder::new("Advanced Course", "advanced-course")
629            .desc("A comprehensive advanced course")
630            .language("fi-FI")
631            .chatbot(true)
632            .module(module)
633            .role(user_id, UserRole::Teacher)
634            .course_id(course_id);
635
636        assert_eq!(course.name, "Advanced Course");
637        assert_eq!(course.slug, "advanced-course");
638        assert_eq!(course.description, "A comprehensive advanced course");
639        assert_eq!(course.language_code, "fi-FI");
640        assert!(course.can_add_chatbot);
641        assert_eq!(course.modules.len(), 1);
642        assert_eq!(course.extra_roles.len(), 1);
643        assert_eq!(course.course_id, Some(course_id));
644    }
645
646    #[test]
647    fn course_builder_default_values() {
648        let course = CourseBuilder::new("Minimal Course", "minimal");
649
650        assert_eq!(course.language_code, "en");
651        assert!(!course.can_add_chatbot);
652        assert_eq!(course.description, "Sample course.");
653        assert_eq!(course.default_instance_name, b"default-instance");
654        assert!(course.modules.is_empty());
655        assert!(course.extra_roles.is_empty());
656        assert!(course.course_id.is_none());
657    }
658
659    #[test]
660    fn course_builder_empty_strings() {
661        let course = CourseBuilder::new("", "");
662
663        assert_eq!(course.name, "");
664        assert_eq!(course.slug, "");
665        assert_eq!(course.language_code, "en");
666        assert_eq!(course.description, "Sample course.");
667    }
668
669    #[test]
670    fn course_builder_method_chaining_order() {
671        let course1 = CourseBuilder::new("Course 1", "course-1")
672            .chatbot(true)
673            .desc("Description 1")
674            .language("fi-FI");
675
676        let course2 = CourseBuilder::new("Course 2", "course-2")
677            .language("fi-FI")
678            .desc("Description 2")
679            .chatbot(true);
680
681        assert_eq!(course1.can_add_chatbot, course2.can_add_chatbot);
682        assert_eq!(course1.language_code, course2.language_code);
683        assert_eq!(course1.description, "Description 1");
684        assert_eq!(course2.description, "Description 2");
685    }
686
687    #[test]
688    fn course_builder_front_page_content() {
689        let custom_content = vec![
690            GutenbergBlock::landing_page_hero_section("Custom Welcome", "Custom Subheading"),
691            GutenbergBlock::course_objective_section(),
692        ];
693
694        let course = CourseBuilder::new("Test Course", "test-course")
695            .front_page_content(custom_content.clone());
696
697        assert_eq!(course.front_page_content, Some(custom_content));
698    }
699
700    #[test]
701    fn course_builder_front_page_content_default() {
702        let course = CourseBuilder::new("Test Course", "test-course");
703
704        assert_eq!(course.front_page_content, None);
705    }
706
707    #[test]
708    fn course_builder_top_level_page() {
709        let course = CourseBuilder::new("Test Course", "test-course")
710            .top_level_page("/welcome", "Welcome Page", 1, false, None)
711            .top_level_page("/hidden", "Hidden Page", 2, true, None);
712
713        assert_eq!(course.pages.len(), 2);
714        assert_eq!(course.pages[0].url, "/welcome");
715        assert_eq!(course.pages[0].title, "Welcome Page");
716        assert_eq!(course.pages[0].page_number, 1);
717        assert!(!course.pages[0].is_hidden);
718        assert_eq!(course.pages[0].content, None);
719
720        assert_eq!(course.pages[1].url, "/hidden");
721        assert_eq!(course.pages[1].title, "Hidden Page");
722        assert_eq!(course.pages[1].page_number, 2);
723        assert!(course.pages[1].is_hidden);
724        assert_eq!(course.pages[1].content, None);
725    }
726
727    #[test]
728    fn course_builder_top_level_page_with_content() {
729        let content = vec![];
730        let course = CourseBuilder::new("Test Course", "test-course").top_level_page(
731            "/content",
732            "Content Page",
733            1,
734            false,
735            Some(content.clone()),
736        );
737
738        assert_eq!(course.pages.len(), 1);
739        assert_eq!(course.pages[0].url, "/content");
740        assert_eq!(course.pages[0].title, "Content Page");
741        assert_eq!(course.pages[0].page_number, 1);
742        assert!(!course.pages[0].is_hidden);
743        assert_eq!(course.pages[0].content, Some(content));
744    }
745
746    #[test]
747    fn course_builder_multiple_top_level_pages() {
748        let course = CourseBuilder::new("Test Course", "test-course")
749            .top_level_page("/welcome", "Welcome", 1, false, None)
750            .top_level_page("/about", "About", 2, false, None)
751            .top_level_page("/secret", "Secret", 3, true, None);
752
753        assert_eq!(course.pages.len(), 3);
754        assert_eq!(course.pages[0].url, "/welcome");
755        assert_eq!(course.pages[1].url, "/about");
756        assert_eq!(course.pages[2].url, "/secret");
757        assert!(course.pages[2].is_hidden);
758    }
759}