@@ -1406,28 +1406,37 @@ def test_load_programs(mocker, mock_blocklist, mock_duplicates):
14061406 mock_duplicates .assert_called_once_with ("mitx" )
14071407
14081408
1409- def test_load_programs_with_child_program_relationships (mocker , settings ):
1410- """End-to-end loader test for mixed child course/program relationships."""
1409+ @pytest .fixture
1410+ def mitxonline_program_children_fixture (mocker , settings ):
1411+ """
1412+ Set up topics/platform, patch _fetch_courses_by_ids, and return the parsed
1413+ mitxonline_program_children_loader JSON fixture.
1414+ """
14111415 set_up_topics (is_mitx = True )
14121416 LearningResourcePlatformFactory .create (code = PlatformType .mitxonline .name )
14131417
14141418 with open ("./test_json/mitxonline_program_children_loader.json" ) as f : # noqa: PTH123
14151419 fixture_data = json .load (f )
14161420
1417- def _mock_fetch_courses_by_ids (course_ids ):
1418- return [
1421+ mocker .patch (
1422+ "learning_resources.etl.mitxonline._fetch_courses_by_ids" ,
1423+ side_effect = lambda course_ids : [
14191424 course
14201425 for course in fixture_data ["courses" ]
14211426 if course ["id" ] in set (course_ids )
1422- ]
1423-
1424- mocker .patch (
1425- "learning_resources.etl.mitxonline._fetch_courses_by_ids" ,
1426- side_effect = _mock_fetch_courses_by_ids ,
1427+ ],
14271428 )
14281429 settings .MITX_ONLINE_BASE_URL = "https://mitxonline.mit.edu"
1430+ return fixture_data
1431+
14291432
1430- transformed_programs = list (transform_programs (fixture_data ["programs" ]))
1433+ def test_load_programs_with_child_program_relationships (
1434+ mitxonline_program_children_fixture ,
1435+ ):
1436+ """End-to-end loader test for mixed child course/program relationships."""
1437+ transformed_programs = list (
1438+ transform_programs (mitxonline_program_children_fixture ["programs" ])
1439+ )
14311440 load_programs (
14321441 ETLSource .mitxonline .name ,
14331442 transformed_programs ,
@@ -1470,43 +1479,177 @@ def _mock_fetch_courses_by_ids(course_ids):
14701479 assert child_program_positions == sorted (set (child_program_positions ))
14711480
14721481
1473- def test_load_programs_idempotent_child_relationships (mocker , settings ):
1474- """Running load_programs twice should not duplicate child program relationships."""
1475- set_up_topics (is_mitx = True )
1476- LearningResourcePlatformFactory .create (code = PlatformType .mitxonline .name )
1477-
1478- with open ("./test_json/mitxonline_program_children_loader.json" ) as f : # noqa: PTH123
1479- fixture_data = json .load (f )
1482+ def test_load_program_honors_explicit_course_position (mock_upsert_tasks ):
1483+ """An explicit `position` on each course_data entry should be honored, preserving gaps for pass 2 to fill."""
1484+ platform = LearningResourcePlatformFactory .create ()
1485+ program = ProgramFactory .build (courses = [], platform = platform .code )
1486+ courses = CourseFactory .create_batch (3 , platform = platform .code )
14801487
1481- def _mock_fetch_courses_by_ids (course_ids ):
1482- return [
1483- course
1484- for course in fixture_data ["courses" ]
1485- if course ["id" ] in set (course_ids )
1486- ]
1488+ # Simulate gaps where pass 2 would later insert display_mode="course"
1489+ # child programs at positions 0 and 2.
1490+ program_courses = [
1491+ {
1492+ "readable_id" : courses [0 ].learning_resource .readable_id ,
1493+ "platform" : platform .code ,
1494+ "availability" : courses [0 ].learning_resource .availability ,
1495+ "position" : 1 ,
1496+ },
1497+ {
1498+ "readable_id" : courses [1 ].learning_resource .readable_id ,
1499+ "platform" : platform .code ,
1500+ "availability" : courses [1 ].learning_resource .availability ,
1501+ "position" : 3 ,
1502+ },
1503+ {
1504+ "readable_id" : courses [2 ].learning_resource .readable_id ,
1505+ "platform" : platform .code ,
1506+ "availability" : courses [2 ].learning_resource .availability ,
1507+ "position" : 4 ,
1508+ },
1509+ ]
14871510
1488- mocker .patch (
1489- "learning_resources.etl.mitxonline._fetch_courses_by_ids" ,
1490- side_effect = _mock_fetch_courses_by_ids ,
1511+ result , _ , _ = load_program (
1512+ {
1513+ "platform" : platform .code ,
1514+ "readable_id" : program .learning_resource .readable_id ,
1515+ "professional" : False ,
1516+ "title" : program .learning_resource .title ,
1517+ "url" : program .learning_resource .url ,
1518+ "image" : {"url" : program .learning_resource .image .url },
1519+ "published" : True ,
1520+ "runs" : [
1521+ {
1522+ "run_id" : program .learning_resource .readable_id ,
1523+ "start_date" : "2024-01-01T00:00:00Z" ,
1524+ "enrollment_start" : "2023-12-01T00:00:00Z" ,
1525+ "end_date" : "2024-06-01T00:00:00Z" ,
1526+ }
1527+ ],
1528+ "availability" : program .learning_resource .availability ,
1529+ "courses" : program_courses ,
1530+ },
1531+ [],
1532+ [],
14911533 )
1492- settings .MITX_ONLINE_BASE_URL = "https://mitxonline.mit.edu"
14931534
1494- transformed = list (transform_programs (fixture_data ["programs" ]))
1535+ positions_by_readable = {
1536+ rel .child .readable_id : rel .position for rel in result .children .all ()
1537+ }
1538+ assert positions_by_readable == {
1539+ courses [0 ].learning_resource .readable_id : 1 ,
1540+ courses [1 ].learning_resource .readable_id : 3 ,
1541+ courses [2 ].learning_resource .readable_id : 4 ,
1542+ }
1543+
14951544
1496- # Run twice
1545+ def test_load_programs_orders_courses_by_req_tree_with_display_mode_course_children (
1546+ mitxonline_program_children_fixture ,
1547+ ):
1548+ """
1549+ PROGRAM_COURSES children should land in req_tree order, interleaving
1550+ display_mode="course" sub-programs among regular courses.
1551+ """
1552+ transformed = list (
1553+ transform_programs (mitxonline_program_children_fixture ["programs" ])
1554+ )
14971555 load_programs (
14981556 ETLSource .mitxonline .name ,
14991557 transformed ,
15001558 config = ProgramLoaderConfig (prune = False ),
15011559 )
1502- # Re-transform since transform_programs pops keys
1503- transformed = list (transform_programs (fixture_data ["programs" ]))
1560+
1561+ parent_resource = LearningResource .objects .get (readable_id = "mitx-parent-program" )
1562+ program_courses = (
1563+ parent_resource .children .filter (
1564+ relation_type = LearningResourceRelationTypes .PROGRAM_COURSES .value
1565+ )
1566+ .order_by ("position" )
1567+ .values_list ("child__readable_id" , flat = True )
1568+ )
1569+ assert list (program_courses ) == [
1570+ "course-10" ,
1571+ "course-70" ,
1572+ "mitx-child-program-displayed-as-course" ,
1573+ ]
1574+
1575+
1576+ def test_load_programs_appends_program_program_children_after_courses (
1577+ mitxonline_program_children_fixture ,
1578+ ):
1579+ """PROGRAM_PROGRAMS children must sit at positions strictly greater than every PROGRAM_COURSES position so pass 2's display_mode="course" children don't collide with them."""
1580+ transformed = list (
1581+ transform_programs (mitxonline_program_children_fixture ["programs" ])
1582+ )
15041583 load_programs (
15051584 ETLSource .mitxonline .name ,
15061585 transformed ,
15071586 config = ProgramLoaderConfig (prune = False ),
15081587 )
15091588
1589+ parent_resource = LearningResource .objects .get (readable_id = "mitx-parent-program" )
1590+ program_courses_max = max (
1591+ parent_resource .children .filter (
1592+ relation_type = LearningResourceRelationTypes .PROGRAM_COURSES .value
1593+ ).values_list ("position" , flat = True )
1594+ )
1595+ program_programs_positions = list (
1596+ parent_resource .children .filter (
1597+ relation_type = LearningResourceRelationTypes .PROGRAM_PROGRAMS .value
1598+ ).values_list ("position" , flat = True )
1599+ )
1600+ assert program_programs_positions , "expected at least one PROGRAM_PROGRAMS child"
1601+ assert min (program_programs_positions ) > program_courses_max
1602+
1603+
1604+ def test_create_child_program_relationships_uses_existing_max_position ():
1605+ """New child without explicit position should land at existing_max + 1."""
1606+ platform = LearningResourcePlatformFactory .create (code = PlatformType .mitxonline .name )
1607+ parent = ProgramFactory .create (
1608+ platform = platform .code ,
1609+ learning_resource__readable_id = "parent-with-existing-children" ,
1610+ ).learning_resource
1611+ existing_child = CourseFactory .create (platform = platform .code ).learning_resource
1612+ LearningResourceRelationship .objects .create (
1613+ parent = parent ,
1614+ child = existing_child ,
1615+ relation_type = LearningResourceRelationTypes .PROGRAM_COURSES .value ,
1616+ position = 5 ,
1617+ )
1618+ new_child_program = ProgramFactory .create (
1619+ platform = platform .code ,
1620+ learning_resource__readable_id = "new-child-program" ,
1621+ ).learning_resource
1622+
1623+ loaders ._create_child_program_relationships ( # noqa: SLF001
1624+ [
1625+ (
1626+ parent ,
1627+ [{"readable_id" : new_child_program .readable_id }],
1628+ )
1629+ ]
1630+ )
1631+
1632+ new_rel = LearningResourceRelationship .objects .get (
1633+ parent = parent , child = new_child_program
1634+ )
1635+ assert new_rel .position == 6
1636+
1637+
1638+ def test_load_programs_idempotent_child_relationships (
1639+ mitxonline_program_children_fixture ,
1640+ ):
1641+ """Running load_programs twice should not duplicate child program relationships."""
1642+ for _ in range (2 ):
1643+ # Re-transform each iteration since transform_programs pops keys.
1644+ transformed = list (
1645+ transform_programs (mitxonline_program_children_fixture ["programs" ])
1646+ )
1647+ load_programs (
1648+ ETLSource .mitxonline .name ,
1649+ transformed ,
1650+ config = ProgramLoaderConfig (prune = False ),
1651+ )
1652+
15101653 parent_resource = LearningResource .objects .get (readable_id = "mitx-parent-program" )
15111654 # Should have exactly 2 child-program relationships, not 4
15121655 program_child_count = parent_resource .children .filter (
0 commit comments