@@ -619,6 +619,55 @@ def test_marketing_page_for_resources_with_webdriver(mocker, settings):
619619 )
620620
621621
622+ @pytest .mark .django_db
623+ def test_marketing_page_for_resources_isolates_scrape_failures (mocker ):
624+ """A single resource's scrape failure must not fail the whole chunk.
625+
626+ When course and program tasks are chained, a chunk that raises poisons the
627+ chord header and the program group never runs, so per-resource failures are
628+ logged and skipped rather than propagated.
629+ """
630+ bad_course = models .LearningResource .objects .create (
631+ title = "Bad Course" ,
632+ url = "https://example.com/bad-course" ,
633+ resource_type = "course" ,
634+ published = True ,
635+ )
636+ good_course = models .LearningResource .objects .create (
637+ title = "Good Course" ,
638+ url = "https://example.com/good-course" ,
639+ resource_type = "course" ,
640+ published = True ,
641+ )
642+
643+ good_scraper = mocker .Mock ()
644+ good_scraper .scrape .return_value = "<html><body><p>ok</p></body></html>"
645+
646+ def fake_scraper_for_site (url ):
647+ if url == bad_course .url :
648+ msg = "scraper boom"
649+ raise RuntimeError (msg )
650+ return good_scraper
651+
652+ mocker .patch (
653+ "learning_resources.tasks.scraper_for_site" ,
654+ side_effect = fake_scraper_for_site ,
655+ )
656+ mocker .patch ("learning_resources.tasks.html_to_markdown" , return_value = "ok" )
657+ mock_generate_embeddings = mocker .patch ("vector_search.tasks.generate_embeddings" )
658+
659+ # Must not raise despite the bad course failing
660+ marketing_page_for_resources ([bad_course .id , good_course .id ])
661+
662+ assert not models .ContentFile .objects .filter (learning_resource = bad_course ).exists ()
663+ good_cf = models .ContentFile .objects .get (
664+ learning_resource = good_course , file_type = MARKETING_PAGE_FILE_TYPE
665+ )
666+ mock_generate_embeddings .delay .assert_called_once_with (
667+ [good_cf .id ], "content_file" , overwrite = True
668+ )
669+
670+
622671@pytest .mark .django_db
623672def test_marketing_page_for_program_appends_children (mocker , settings ):
624673 """Test that marketing_page_for_resources appends program children content"""
@@ -780,6 +829,104 @@ def test_scrape_marketing_pages(mocker, settings, mocked_celery):
780829 mock_group .assert_called_once ()
781830
782831
832+ @pytest .mark .django_db
833+ def test_scrape_marketing_pages_orders_courses_before_programs (
834+ mocker , settings , mocked_celery
835+ ):
836+ """Courses are scraped in a group that runs before the programs group."""
837+ settings .QDRANT_CHUNK_SIZE = 10
838+ course = models .LearningResource .objects .create (
839+ title = "Course" ,
840+ url = "https://example.com/course" ,
841+ resource_type = "course" ,
842+ published = True ,
843+ )
844+ program = models .LearningResource .objects .create (
845+ title = "Program" ,
846+ url = "https://example.com/program" ,
847+ resource_type = "program" ,
848+ published = True ,
849+ )
850+ si_mock = mocker .patch ("learning_resources.tasks.marketing_page_for_resources.si" )
851+ # Make each .si(...) call's return value identify which ids it was built
852+ # from, so the args passed into celery.group(...) can be told apart.
853+ si_mock .side_effect = lambda ids : ("si" , tuple (ids ))
854+
855+ with pytest .raises (mocked_celery .replace_exception_class ):
856+ scrape_marketing_pages .delay ()
857+
858+ # A chain enforces ordering (not a single flat group).
859+ assert mocked_celery .chain .called
860+ # Course task built before program task; each in its own chunk here.
861+ queued_ids = [call .args [0 ] for call in si_mock .call_args_list ]
862+ assert queued_ids [0 ] == [course .id ]
863+ assert [program .id ] in queued_ids
864+ assert queued_ids .index ([course .id ]) < queued_ids .index ([program .id ])
865+
866+ # Pin the guarantee to what is actually fed into celery.chain via
867+ # celery.group: Python evaluates chain's positional args left-to-right,
868+ # so the first group(...) call must be the course tasks and the second
869+ # must be the program tasks. This fails if the two arguments to
870+ # celery.chain(celery.group(...), celery.group(...)) are ever swapped.
871+ assert mocked_celery .group .call_count == 2
872+ first_group_tasks , second_group_tasks = (
873+ call .args [0 ] for call in mocked_celery .group .call_args_list
874+ )
875+ assert first_group_tasks == [("si" , (course .id ,))]
876+ assert second_group_tasks == [("si" , (program .id ,))]
877+
878+
879+ @pytest .mark .django_db
880+ def test_scrape_marketing_pages_queues_healable_programs (
881+ mocker , settings , mocked_celery
882+ ):
883+ """A program that already has a page but is missing its children section
884+ (with a child course page available) is queued for re-scrape.
885+ """
886+ settings .QDRANT_CHUNK_SIZE = 10
887+ course = models .LearningResource .objects .create (
888+ title = "Course" ,
889+ url = "https://example.com/course" ,
890+ resource_type = "course" ,
891+ published = True ,
892+ )
893+ ContentFile .objects .create (
894+ learning_resource = course ,
895+ file_type = MARKETING_PAGE_FILE_TYPE ,
896+ file_extension = ".md" ,
897+ key = course .url ,
898+ content = "Child copy." ,
899+ published = True ,
900+ )
901+ program = models .LearningResource .objects .create (
902+ title = "Program" ,
903+ url = "https://example.com/program" ,
904+ resource_type = "program" ,
905+ published = True ,
906+ )
907+ models .LearningResourceRelationship .objects .create (
908+ parent = program , child = course , relation_type = "PROGRAM_COURSES"
909+ )
910+ # Program already has a page, but WITHOUT the children marker.
911+ ContentFile .objects .create (
912+ learning_resource = program ,
913+ file_type = MARKETING_PAGE_FILE_TYPE ,
914+ file_extension = ".md" ,
915+ key = program .url ,
916+ content = "Program page, no children yet." ,
917+ published = True ,
918+ )
919+ si_mock = mocker .patch ("learning_resources.tasks.marketing_page_for_resources.si" )
920+
921+ with pytest .raises (mocked_celery .replace_exception_class ):
922+ scrape_marketing_pages .delay ()
923+
924+ queued_ids = [i for call in si_mock .call_args_list for i in call .args [0 ]]
925+ # Program re-queued for healing; course already has a page so it is NOT queued.
926+ assert program .id in queued_ids
927+ assert course .id not in queued_ids
928+
929+
783930@pytest .mark .parametrize ("canvas_ids" , [["1" ], None ])
784931def test_sync_canvas_courses (settings , mocker , django_assert_num_queries , canvas_ids ):
785932 """
0 commit comments