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