diff --git a/.upgradenotes/MDL-87621-2026012007295182.yml b/.upgradenotes/MDL-87621-2026012007295182.yml new file mode 100644 index 00000000000..c695312d0c5 --- /dev/null +++ b/.upgradenotes/MDL-87621-2026012007295182.yml @@ -0,0 +1,8 @@ +issueNumber: MDL-87621 +notes: + mod_subsection: + - message: >- + When restoring backups, subsection descriptions are now ignored. This + change ensures that subsection do not incorrectly restore legacy + summary. + type: changed diff --git a/.upgradenotes/MDL-87621-2026012007344637.yml b/.upgradenotes/MDL-87621-2026012007344637.yml new file mode 100644 index 00000000000..6a5e7beb6d6 --- /dev/null +++ b/.upgradenotes/MDL-87621-2026012007344637.yml @@ -0,0 +1,9 @@ +issueNumber: MDL-87621 +notes: + mod_subsection: + - message: >- + The subsection generator now includes support for the `summary` field. + This has been added specifically to test migration tool compatibility + and will be removed in Moodle 7.0. Developers should use this field only + for testing migration workflows. + type: improved diff --git a/.upgradenotes/MDL-87621-2026012107064366.yml b/.upgradenotes/MDL-87621-2026012107064366.yml new file mode 100644 index 00000000000..8adbb8796fd --- /dev/null +++ b/.upgradenotes/MDL-87621-2026012107064366.yml @@ -0,0 +1,9 @@ +issueNumber: MDL-87621 +notes: + mod_subsection: + - message: >- + A new ad-hoc task, `remove_existing_descriptions`, has been added. This + task will remove the descriptions for all existing subsection instances. + To ensure system stability, the task processes records in batches of 100 + and clears the original description upon completion. + type: improved diff --git a/.upgradenotes/MDL-87621-2026012107393692.yml b/.upgradenotes/MDL-87621-2026012107393692.yml new file mode 100644 index 00000000000..3ed51459db9 --- /dev/null +++ b/.upgradenotes/MDL-87621-2026012107393692.yml @@ -0,0 +1,8 @@ +issueNumber: MDL-87621 +notes: + mod_subsection: + - message: >- + The `manager::clear_description()` method has been added to remove + legacy data. When called, it deletes the description text associated + with a subsection and any files linked to that description. + type: improved diff --git a/public/mod/subsection/backup/moodle2/restore_subsection_activity_task.class.php b/public/mod/subsection/backup/moodle2/restore_subsection_activity_task.class.php index c49739fed24..1ff26ea025a 100644 --- a/public/mod/subsection/backup/moodle2/restore_subsection_activity_task.class.php +++ b/public/mod/subsection/backup/moodle2/restore_subsection_activity_task.class.php @@ -91,4 +91,14 @@ class restore_subsection_activity_task extends restore_activity_task { return $rules; } + + /** + * This function, executed after all the tasks in the plan have been executed. + * This must be done here and not in normal execution steps because the subsection can be restored after the section. + */ + public function after_restore() { + // Clear subsection descriptions (they were removed from mod_subsection in Moodle 5.2). + mod_subsection\manager::create_from_id($this->get_courseid(), $this->get_activityid()) + ->clear_description(); + } } diff --git a/public/mod/subsection/classes/manager.php b/public/mod/subsection/classes/manager.php index 24c32953c00..14a89f356a8 100644 --- a/public/mod/subsection/classes/manager.php +++ b/public/mod/subsection/classes/manager.php @@ -205,4 +205,43 @@ class manager { } return $delegatedsection; } + + /** + * Deletes the subsection description and its associated files. + * Descriptions are no longer supported for subsections since Moodle 5.2. + */ + public function clear_description(): void { + global $DB; + + // Find and delete the files from the subsection summary. + $fs = get_file_storage(); + $coursesectionid = $DB->get_field( + 'course_sections', + 'id', + [ + 'component' => 'mod_subsection', + 'itemid' => $this->instance->id, + ], + ); + $files = $fs->get_area_files( + contextid: \context_course::instance($this->cm->course)->id, + component: 'course', + filearea: 'section', + itemid: $coursesectionid, + ); + foreach ($files as $file) { + $file->delete(); + } + + // Remove the subsection summary. + $DB->set_field( + 'course_sections', + 'summary', + '', + [ + 'component' => 'mod_subsection', + 'itemid' => $this->instance->id, + ], + ); + } } diff --git a/public/mod/subsection/classes/task/remove_subsection_descriptions_task.php b/public/mod/subsection/classes/task/remove_subsection_descriptions_task.php new file mode 100644 index 00000000000..d4c47ebf431 --- /dev/null +++ b/public/mod/subsection/classes/task/remove_subsection_descriptions_task.php @@ -0,0 +1,72 @@ +. + +namespace mod_subsection\task; + +use core\task\adhoc_task; +use mod_subsection\manager; + +/** + * An ad-hoc task to remove existing descriptions from subsection instances. + * + * NOTE: + * - This task processes subsections in batches of 100 to reduce server overload. + * - It will be removed in Moodle 7.0. By then, the remaining descriptions will be removed. + * + * @package mod_subsection + * @copyright 2026 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class remove_subsection_descriptions_task extends adhoc_task { + /** + * Execute the task. + */ + public function execute(): void { + global $DB; + + // Process subsections in batches to reduce server overload. + $removedcount = 0; + $subsections = $DB->get_recordset_select( + table: 'course_sections', + select: 'component = :component AND summary != :empty', + params: ['component' => 'mod_subsection', 'empty' => ''], + limitnum: 100, + ); + $transaction = $DB->start_delegated_transaction(); + foreach ($subsections as $subsection) { + manager::create_from_id($subsection->course, $subsection->itemid)->clear_description(); + $removedcount++; + } + $transaction->allow_commit(); + if ($removedcount > 0) { + mtrace('Subsection descriptions removal task completed. Total removed subsection descriptions: ' . $removedcount); + } else { + mtrace('No subsection descriptions found to remove.'); + } + $subsections->close(); + + $pendingcount = $DB->count_records_select( + table: 'course_sections', + select: 'component = :component AND summary != :empty', + params: ['component' => 'mod_subsection', 'empty' => ''], + ); + if ($pendingcount > 0) { + $task = new self(); + \core\task\manager::queue_adhoc_task($task); + mtrace('Subsection descriptions removal task pending subsections: ' . $pendingcount . '. Scheduled new ad-hoc task.'); + } + } +} diff --git a/public/mod/subsection/cleandescriptions.php b/public/mod/subsection/cleandescriptions.php index f5170e0323a..b4b055896f6 100644 --- a/public/mod/subsection/cleandescriptions.php +++ b/public/mod/subsection/cleandescriptions.php @@ -27,7 +27,6 @@ require_once('../../config.php'); require_admin(); $action = required_param('action', PARAM_ALPHA); -$count = optional_param('count', 0, PARAM_INT); $return = new moodle_url('/admin/settings.php', ['section' => 'mod_subsection_settings']); $PAGE->set_url('/mod/subsection/cleandescriptions.php'); @@ -35,14 +34,9 @@ $PAGE->set_context(context_system::instance()); require_sesskey(); if ($action === 'delete') { - // Remove all existing subsection descriptions. - $DB->set_field('course_sections', 'summary', '', ['component' => 'mod_subsection']); - redirect( - $return, - get_string('descriptionsdeletedsuccess', 'mod_subsection', $count), - null, - \core\output\notification::NOTIFY_SUCCESS - ); + // Schedule the ad-hoc task to remove subsection descriptions. + \core\task\manager::queue_adhoc_task(new \mod_subsection\task\remove_subsection_descriptions_task(), true); + redirect($return); } else if ($action === 'migrate') { // Schedule the ad-hoc task to migrate subsection descriptions. \core\task\manager::queue_adhoc_task(new \mod_subsection\task\migrate_subsection_descriptions_task(), true); diff --git a/public/mod/subsection/lang/en/subsection.php b/public/mod/subsection/lang/en/subsection.php index b82aff3bd7a..937e6f47b7d 100644 --- a/public/mod/subsection/lang/en/subsection.php +++ b/public/mod/subsection/lang/en/subsection.php @@ -37,7 +37,8 @@ $string['deleteconfirmbutton'] = 'Delete all descriptions'; $string['deleteconfirmtext'] = 'This will permanently delete {$a} subsection descriptions from the database.

You can\'t undo this. Are you sure you want to delete all descriptions?'; $string['deleteconfirmtitle'] = 'Delete all subsection descriptions?'; $string['deletelinktext'] = 'Delete descriptions'; -$string['descriptionsdeletedsuccess'] = '{$a} subsection descriptions deleted.'; +$string['descriptionsdeletedpending'] = 'Subsection descriptions waiting to be deleted: {$a}'; +$string['descriptionsdeletedsuccess'] = 'The removal task for all subsection descriptions has been created. This task will run in the background and may take a few minutes.'; $string['descriptionsmigratedpending'] = 'Subsection descriptions waiting to be migrated: {$a}'; $string['descriptionsmigratedsuccess'] = 'The migration task for all subsection descriptions has been created. This task will run in the background and may take a few minutes.'; $string['invalidaction'] = 'Invalid action specified.'; diff --git a/public/mod/subsection/settings.php b/public/mod/subsection/settings.php index 41ef83e9d16..e98223c276e 100644 --- a/public/mod/subsection/settings.php +++ b/public/mod/subsection/settings.php @@ -35,8 +35,9 @@ if ($hassiteconfig) { select: 'component = :component AND summary != :empty', params: ['component' => 'mod_subsection', 'empty' => ''], ); - $task = \core\task\manager::get_queued_adhoc_task_record(new \mod_subsection\task\migrate_subsection_descriptions_task()); - if ($task) { + if ( + \core\task\manager::get_queued_adhoc_task_record(new \mod_subsection\task\migrate_subsection_descriptions_task()) + ) { // There is a pending migration task, show notification and pending count. $notification = $OUTPUT->notification( get_string('descriptionsmigratedsuccess', 'mod_subsection'), @@ -52,6 +53,24 @@ if ($hassiteconfig) { '', new lang_string('descriptionsmigratedpending', 'mod_subsection', $count), )); + } else if ( + \core\task\manager::get_queued_adhoc_task_record(new \mod_subsection\task\remove_subsection_descriptions_task()) + ) { + // There is a pending removal task, show notification and pending count. + $notification = $OUTPUT->notification( + get_string('descriptionsdeletedsuccess', 'mod_subsection'), + \core\output\notification::NOTIFY_SUCCESS, + ); + $settings->add(new admin_setting_heading( + 'removedescriptionsnotification', + '', + $notification, + )); + $settings->add(new admin_setting_heading( + 'pendingcleandescriptions', + '', + new lang_string('descriptionsdeletedpending', 'mod_subsection', $count), + )); } else if ($count > 0) { // Show migration and deletion links. $migrateaction = new \confirm_action( @@ -61,7 +80,7 @@ if ($hassiteconfig) { ); $migrateurl = new moodle_url( '/mod/subsection/cleandescriptions.php', - ['action' => 'migrate', 'count' => $count, 'sesskey' => sesskey()], + ['action' => 'migrate', 'sesskey' => sesskey()], ); $migratelink = $OUTPUT->action_link( url: $migrateurl, @@ -78,7 +97,7 @@ if ($hassiteconfig) { ); $deleteurl = new moodle_url( '/mod/subsection/cleandescriptions.php', - ['action' => 'delete', 'count' => $count, 'sesskey' => sesskey()], + ['action' => 'delete', 'sesskey' => sesskey()], ); $deletelink = $OUTPUT->action_link( url: $deleteurl, diff --git a/public/mod/subsection/tests/behat/subsection_clean_descriptions.feature b/public/mod/subsection/tests/behat/subsection_clean_descriptions.feature index 7aa8f8ad2c8..2d5f8c902f3 100644 --- a/public/mod/subsection/tests/behat/subsection_clean_descriptions.feature +++ b/public/mod/subsection/tests/behat/subsection_clean_descriptions.feature @@ -1,4 +1,4 @@ -@mod @mod_subsection @_file_upload +@mod @mod_subsection Feature: Subsection clean descriptions In order to manage subsection descriptions As an administrator @@ -8,19 +8,17 @@ Feature: Subsection clean descriptions Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | - And the following config values are set as admin: - | enableasyncbackup | 0 | - And I am on the "Course 1" "restore" page logged in as "admin" - And I press "Manage course backups" - And I upload "mod/subsection/tests/fixtures/subsections_with_descriptions.mbz" file to "Files" filemanager - And I press "Save changes" - And I restore "subsections_with_descriptions.mbz" backup into a new course using this options: - | Schema | Course name | Course 2 | - | Schema | Course short name | C2 | + | Course 2 | C2 | 0 | + And the following "activities" exist: + | activity | name | course | idnumber | section | summary | + | subsection | Subsection1 | C1 | subsection1 | 1 | Test Subsection1 summary | + | subsection | subsection3 | C1 | subsection3 | 1 | | + | subsection | subsection2 | C2 | subsection2 | 1 | Test Subsection2 summary | @javascript Scenario: Migrate subsection descriptions - Given I navigate to "Plugins > Activity modules > Subsection" in site administration + Given I log in as "admin" + And I navigate to "Plugins > Activity modules > Subsection" in site administration And I should see "This site has 2 subsection descriptions that are no longer visible to users." When I click on "Migrate descriptions" "link" in the "region-main" "region" And I should see "This will migrate 2 subsection descriptions to Text and Media areas." in the "Migrate all subsection descriptions?" "dialogue" @@ -36,11 +34,17 @@ Feature: Subsection clean descriptions @javascript Scenario: Delete subsection descriptions - Given I navigate to "Plugins > Activity modules > Subsection" in site administration + Given I log in as "admin" + And I navigate to "Plugins > Activity modules > Subsection" in site administration And I should see "This site has 2 subsection descriptions that are no longer visible to users." When I click on "Delete descriptions" "link" in the "region-main" "region" And I should see "This will permanently delete 2 subsection descriptions from the database." in the "Delete all subsection descriptions?" "dialogue" And I click on "Delete all descriptions" "button" in the "Delete all subsection descriptions?" "dialogue" - Then I should see "2 subsection descriptions deleted." in the "region-main" "region" + Then I should see "The removal task for all subsection descriptions has been created." in the "region-main" "region" + And I should see "Subsection descriptions waiting to be deleted: 2" in the "region-main" "region" And I reload the page - And I should not see "Subsection pages and descriptions are no longer supported in Moodle 5.2" + And I should see "The removal task for all subsection descriptions has been created." in the "region-main" "region" + And I should see "Subsection descriptions waiting to be deleted: 2" in the "region-main" "region" + And I run all adhoc tasks + And I reload the page + And I should not see "Subsection descriptions waiting to be deleted:" in the "region-main" "region" diff --git a/public/mod/subsection/tests/behat/subsection_restore_description.feature b/public/mod/subsection/tests/behat/subsection_restore_description.feature new file mode 100644 index 00000000000..09832b99d5c --- /dev/null +++ b/public/mod/subsection/tests/behat/subsection_restore_description.feature @@ -0,0 +1,25 @@ +@mod @mod_subsection @_file_upload +Feature: Subsection restore backup with descriptions + In order to manage subsection descriptions + As an administrator + I want to be able to restore backups containing subsection descriptions ignoring them + + Background: + Given the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following config values are set as admin: + | enableasyncbackup | 0 | + And I am on the "Course 1" "restore" page logged in as "admin" + And I press "Manage course backups" + And I upload "mod/subsection/tests/fixtures/subsections_with_descriptions.mbz" file to "Files" filemanager + And I press "Save changes" + + @javascript + Scenario: Check subsection descriptions are not restored + Given I restore "subsections_with_descriptions.mbz" backup into a new course using this options: + | Schema | Course name | Course 2 | + | Schema | Course short name | C2 | + When I navigate to "Plugins > Activity modules > Subsection" in site administration + # If this message appears, it means that the descriptions were restored. + Then I should not see "This site has 2 subsection descriptions that are no longer visible to users." diff --git a/public/mod/subsection/tests/generator/lib.php b/public/mod/subsection/tests/generator/lib.php index 6379bf6e3bc..553c5e858af 100644 --- a/public/mod/subsection/tests/generator/lib.php +++ b/public/mod/subsection/tests/generator/lib.php @@ -23,4 +23,29 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mod_subsection_generator extends testing_module_generator { + #[\Override] + public function create_instance($record = null, ?array $options = null): stdClass { + global $DB; + + // Ensure the record can be modified without affecting calling code. + $record = (object)(array)$record; + + // Create the subsection instance. + $instance = parent::create_instance($record, (array)$options); + + // Update the delegated section summary if needed. + if (isset($record->summary)) { + $DB->set_field( + 'course_sections', + 'summary', + $record->summary, + [ + 'component' => 'mod_subsection', + 'itemid' => $instance->id, + ], + ); + } + + return $instance; + } } diff --git a/public/mod/subsection/tests/generator_test.php b/public/mod/subsection/tests/generator_test.php new file mode 100644 index 00000000000..d9c8be4fa47 --- /dev/null +++ b/public/mod/subsection/tests/generator_test.php @@ -0,0 +1,67 @@ +. + +namespace mod_subsection; + +/** + * Generator tests class for mod_subsection. + * + * @package mod_subsection + * @category test + * @copyright 2026 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\PHPUnit\Framework\Attributes\CoversClass(mod_subsection_generator::class)] +final class generator_test extends \advanced_testcase { + /** + * Test on subsection creation. + */ + public function test_create_instance(): void { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + + // Create one subsection activity (empty summary by default). + $this->assertFalse($DB->record_exists('subsection', ['course' => $course->id])); + $activity1 = $this->getDataGenerator()->create_module('subsection', ['course' => $course]); + $records = $DB->get_records('subsection', ['course' => $course->id], 'id'); + $this->assertEquals(1, count($records)); + $this->assertTrue(array_key_exists($activity1->id, $records)); + $section = $DB->get_record('course_sections', [ + 'component' => 'mod_subsection', + 'itemid' => $activity1->id, + ]); + $this->assertEquals('', $section->summary); + + // Create another subsection activity with a specific summary. + $summarytext = 'This is a test summary'; + $activity2 = $this->getDataGenerator()->create_module('subsection', [ + 'course' => $course, + 'summary' => $summarytext, + ]); + $records = $DB->get_records('subsection', ['course' => $course->id], 'id'); + $this->assertEquals(2, count($records)); + $this->assertTrue(array_key_exists($activity2->id, $records)); + // Check that the delegated section summary has been correctly set. + $section = $DB->get_record('course_sections', [ + 'component' => 'mod_subsection', + 'itemid' => $activity2->id, + ]); + $this->assertEquals($summarytext, $section->summary); + } +} diff --git a/public/mod/subsection/tests/manager_test.php b/public/mod/subsection/tests/manager_test.php index 8f3e3f9f728..343cdf45062 100644 --- a/public/mod/subsection/tests/manager_test.php +++ b/public/mod/subsection/tests/manager_test.php @@ -126,4 +126,138 @@ final class manager_test extends \advanced_testcase { ]; } + + /** + * Test clear_description. + */ + public function test_clear_description(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + // Add a couple of subsections with file in the description. + $module1 = $this->getDataGenerator()->create_module('subsection', [ + 'course' => $course->id, + 'section' => 1, + 'summary' => 'Subsection text with link', + ]); + $subsection1 = $DB->get_record( + 'course_sections', + ['course' => $course->id, 'itemid' => $module1->id], + ); + $filerecord = [ + 'component' => 'course', + 'filearea' => 'section', + 'contextid' => \context_course::instance($course->id)->id, + 'itemid' => $subsection1->id, + 'filename' => 'intro1.txt', + 'filepath' => '/', + ]; + $fs = get_file_storage(); + $fs->create_file_from_string($filerecord, 'Test intro file'); + + $module2 = $this->getDataGenerator()->create_module('subsection', [ + 'course' => $course->id, + 'section' => 1, + 'summary' => 'Subsection text with link', + ]); + $subsection2 = $DB->get_record( + 'course_sections', + ['course' => $course->id, 'itemid' => $module2->id], + ); + $filerecord = [ + 'component' => 'course', + 'filearea' => 'section', + 'contextid' => \context_course::instance($course->id)->id, + 'itemid' => $subsection2->id, + 'filename' => 'intro2.txt', + 'filepath' => '/', + ]; + $fs = get_file_storage(); + $fs->create_file_from_string($filerecord, 'Test intro file'); + // Add one more subsection with description but no files. + $module3 = $this->getDataGenerator()->create_module('subsection', [ + 'course' => $course->id, + 'section' => 1, + 'summary' => 'Subsection text with no files', + ]); + + // Check subsections have descriptions. + $this->assertEquals( + 3, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 2, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND (filename != :filename)', + [ + 'component' => 'course', + 'filearea' => 'section', + 'filename' => '.', + ], + ), + ); + + // Clear the description for subsection with files. + $manager = manager::create_from_id($course->id, $module1->id); + $manager->clear_description(); + + // Check only subsection description and its files have been removed. + $this->assertEquals( + 2, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + // Check the file has been removed too. + $this->assertEquals( + 1, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND (filename != :filename)', + [ + 'component' => 'course', + 'filearea' => 'section', + 'filename' => '.', + ], + ), + ); + + // Clear the description for subsection without files. + $manager = manager::create_from_id($course->id, $module3->id); + $manager->clear_description(); + + // Check only subsection3 description has been removed. + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + // Check no files have been removed. + $this->assertEquals( + 1, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND (filename != :filename)', + [ + 'component' => 'course', + 'filearea' => 'section', + 'filename' => '.', + ], + ), + ); + } } diff --git a/public/mod/subsection/tests/task/migrate_subsection_descriptions_task_test.php b/public/mod/subsection/tests/task/migrate_subsection_descriptions_task_test.php index 8a3c0c17867..ddccd69923a 100644 --- a/public/mod/subsection/tests/task/migrate_subsection_descriptions_task_test.php +++ b/public/mod/subsection/tests/task/migrate_subsection_descriptions_task_test.php @@ -35,8 +35,12 @@ final class migrate_subsection_descriptions_task_test extends \advanced_testcase $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + // Add subsection with description. $summarytext = 'Section with description'; - $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + $this->getDataGenerator()->create_module( + 'subsection', + ['course' => $course->id, 'section' => 1, 'summary' => $summarytext], + ); // Add forum to the subsection to test the order of the modules is preserved. $this->getDataGenerator()->create_module( 'forum', @@ -46,25 +50,9 @@ final class migrate_subsection_descriptions_task_test extends \advanced_testcase 'section' => 2, ], ); - // Add description to course sections and the subsection. - $DB->set_field( - 'course_sections', - 'summary', - $summarytext, - ['course' => $course->id], - ); // Add another subsection without description. $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); - // Check only 2 sections and 1 subsection have description. - $this->assertEquals( - 3, - $DB->count_records_select( - 'course_sections', - 'course = :courseid AND summary != \'\'', - ['courseid' => $course->id], - ), - ); $this->assertEquals( 2, $DB->count_records_select( @@ -113,15 +101,6 @@ final class migrate_subsection_descriptions_task_test extends \advanced_testcase 'Subsection descriptions migration task completed. Total migrated subsections: 1', trim($output), ); - // Check only 2 sections keep having description after running the task. - $this->assertEquals( - 2, - $DB->count_records_select( - 'course_sections', - 'course = :courseid AND summary != \'\'', - ['courseid' => $course->id], - ), - ); // Check no subsection has description after running the task. $this->assertEquals( 0, @@ -184,19 +163,16 @@ final class migrate_subsection_descriptions_task_test extends \advanced_testcase $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + // Add subsection with file in the description. $summarytext = 'Subsection text with link'; - $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + $this->getDataGenerator()->create_module( + 'subsection', + ['course' => $course->id, 'section' => 1, 'summary' => $summarytext], + ); $subsection = $DB->get_record( 'course_sections', ['course' => $course->id, 'section' => 2], ); - // Add description to the subsection. - $DB->set_field( - 'course_sections', - 'summary', - $summarytext, - ['course' => $course->id, 'section' => $subsection->section], - ); $filerecord = [ 'component' => 'course', 'filearea' => 'section', @@ -321,24 +297,13 @@ final class migrate_subsection_descriptions_task_test extends \advanced_testcase $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); - $summarytext = 'Section with description'; - $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); - // Add description to course sections and the subsection. - $DB->set_field( - 'course_sections', - 'summary', - $summarytext, - ['course' => $course->id], - ); - // Check only 2 sections and 1 subsection have description. - $this->assertEquals( - 3, - $DB->count_records_select( - 'course_sections', - 'course = :courseid AND summary != \'\'', - ['courseid' => $course->id], - ), + // Add subsection with description. + $this->getDataGenerator()->create_module( + 'subsection', + ['course' => $course->id, 'section' => 1, 'summary' => 'Summary text'], ); + + // Check subsection has description. $this->assertEquals( 1, $DB->count_records_select( @@ -433,18 +398,15 @@ final class migrate_subsection_descriptions_task_test extends \advanced_testcase $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + // Add subsections with description. for ($i = 0; $i < 101; $i++) { - $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + $this->getDataGenerator()->create_module( + 'subsection', + ['course' => $course->id, 'section' => 1, 'summary' => 'Summary text'], + ); } - // Add description to course sections and subsections. - $DB->set_field( - 'course_sections', - 'summary', - 'Section with description', - ['course' => $course->id], - ); - // Check 101 subsections have description. + // Check all subsections have description. $this->assertEquals( 101, $DB->count_records_select( diff --git a/public/mod/subsection/tests/task/remove_subsection_descriptions_task_test.php b/public/mod/subsection/tests/task/remove_subsection_descriptions_task_test.php new file mode 100644 index 00000000000..e8f14d7a110 --- /dev/null +++ b/public/mod/subsection/tests/task/remove_subsection_descriptions_task_test.php @@ -0,0 +1,276 @@ +. + +namespace mod_subsection\task; + +/** + * Class containing unit tests for the remove existing subsection descriptions task. + * + * @package mod_subsection + * @copyright 2026 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\PHPUnit\Framework\Attributes\CoversClass(remove_subsection_descriptions_task::class)] +final class remove_subsection_descriptions_task_test extends \advanced_testcase { + /** + * Test remove_subsection_descriptions task. + */ + public function test_remove_subsection_descriptions(): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + // Add subsection with description. + $summarytext = 'Section with description'; + $this->getDataGenerator()->create_module( + 'subsection', + ['course' => $course->id, 'section' => 1, 'summary' => $summarytext], + ); + // Add another subsection without description. + $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + + // Check only 1 subsection has description. + $this->assertEquals( + 2, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + + // Run the task. + $task = new remove_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(remove_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + + // Check one subsection removed message shown. + $this->assertStringContainsString( + 'Subsection descriptions removal task completed. Total removed subsection descriptions: 1', + trim($output), + ); + // Check no subsection has description after running the task. + $this->assertEquals( + 0, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + + // Check no subsections left to remove. + $task = new remove_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(remove_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + $this->assertStringContainsString( + 'No subsection descriptions found to remove.', + trim($output), + ); + } + + /** + * Test remove_subsection_descriptions task with attached files. + */ + public function test_remove_subsection_descriptions_with_files(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + // Add subsection with file in the description. + $summarytext = 'Subsection text with link'; + $this->getDataGenerator()->create_module( + 'subsection', + ['course' => $course->id, 'section' => 1, 'summary' => $summarytext], + ); + $subsection = $DB->get_record( + 'course_sections', + ['course' => $course->id, 'section' => 2], + ); + $filerecord = [ + 'component' => 'course', + 'filearea' => 'section', + 'contextid' => \context_course::instance($course->id)->id, + 'itemid' => $subsection->id, + 'filename' => 'intro.txt', + 'filepath' => '/', + ]; + $fs = get_file_storage(); + $fs->create_file_from_string($filerecord, 'Test intro file'); + + // Check subsection has description with file. + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 1, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND filename = :filename', + [ + 'component' => 'course', + 'filearea' => 'section', + 'filename' => 'intro.txt', + ], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND filename = :filename', + [ + 'component' => 'mod_label', + 'filearea' => 'intro', + 'filename' => 'intro.txt', + ], + ), + ); + + // Run the task. + $task = new remove_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(remove_subsection_descriptions_task::class); + ob_end_clean(); + + // Check no subsection has description after running the task. + $this->assertEquals( + 0, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + + // Check the file has been removed too. + $this->assertEquals( + 0, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND filename = :filename', + [ + 'component' => 'course', + 'filearea' => 'section', + 'filename' => 'intro.txt', + ], + ), + ); + } + + /** + * Test remove_subsection_descriptions task reschedule when more than 100 subsections to process. + */ + public function test_remove_subsection_descriptions_rescheduletask(): void { + global $DB; + + if (!PHPUNIT_LONGTEST) { + $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); + } + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + // Add subsections with description. + for ($i = 0; $i < 101; $i++) { + $this->getDataGenerator()->create_module( + 'subsection', + ['course' => $course->id, 'section' => 1, 'summary' => 'Summary text'], + ); + } + + // Check all subsections have description. + $this->assertEquals( + 101, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\'', + ['courseid' => $course->id], + ), + ); + + // Run the task. + $task = new remove_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(remove_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + + // Check subsection removed message shown. + $this->assertStringContainsString( + 'Subsection descriptions removal task completed. Total removed subsection descriptions: 100', + trim($output), + ); + $this->assertStringContainsString( + 'Subsection descriptions removal task pending subsections: 1. Scheduled new ad-hoc task.', + trim($output), + ); + // Check only 1 subsection keep having description after running the task. + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + + // Re-run the task to process the remaining subsection (it should have been queued by the previous run). + ob_start(); + $this->runAdhocTasks(remove_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertStringContainsString( + 'Subsection descriptions removal task completed. Total removed subsection descriptions: 1', + trim($output), + ); + // Check no subsections keep having description after running the task. + $this->assertEquals( + 0, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + } +}