MDL-60062 quiz: add support for drag drop of calendar events
[moodle.git] / mod / quiz / tests / calendar_event_modified_test.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Unit tests for the calendar event modification callbacks used
19  * for dragging and dropping quiz calendar events in the calendar
20  * UI.
21  *
22  * @package    mod_quiz
23  * @category   test
24  * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
25  * @license    http://www.gnu.org/copyleft/gpl.html GNU Public License
26  */
28 defined('MOODLE_INTERNAL') || die();
30 global $CFG;
31 require_once($CFG->dirroot . '/mod/quiz/lib.php');
33 /**
34  * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
35  * @license    http://www.gnu.org/copyleft/gpl.html GNU Public License
36  */
37 class mod_quiz_calendar_event_modified_testcase extends advanced_testcase {
39     /**
40      * Create an instance of the quiz activity.
41      *
42      * @param array $properties Properties to set on the activity
43      * @return stdClass Quiz activity instance
44      */
45     protected function create_quiz_instance(array $properties) {
46         global $DB;
48         $generator = $this->getDataGenerator();
50         if (empty($properties['course'])) {
51             $course = $generator->create_course();
52             $courseid = $course->id;
53         } else {
54             $courseid = $properties['course'];
55         }
57         $quizgenerator = $generator->get_plugin_generator('mod_quiz');
58         $quiz = $quizgenerator->create_instance(array_merge(['course' => $courseid], $properties));
60         if (isset($properties['timemodified'])) {
61             // The generator overrides the timemodified value to set it as
62             // the current time even if a value is provided so we need to
63             // make sure it's set back to the requested value.
64             $quiz->timemodified = $properties['timemodified'];
65             $DB->update_record('quiz', $quiz);
66         }
68         return $quiz;
69     }
71     /**
72      * Create a calendar event for a quiz activity instance.
73      *
74      * @param stdClass $quiz The activity instance
75      * @param array $eventproperties Properties to set on the calendar event
76      * @return calendar_event
77      */
78     protected function create_quiz_calendar_event(\stdClass $quiz, array $eventproperties) {
79         $defaultproperties = [
80             'name' => 'Test event',
81             'description' => '',
82             'format' => 1,
83             'courseid' => $quiz->course,
84             'groupid' => 0,
85             'userid' => 2,
86             'modulename' => 'quiz',
87             'instance' => $quiz->id,
88             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
89             'timestart' => time(),
90             'timeduration' => 86400,
91             'visible' => 1
92         ];
94         return new \calendar_event(array_merge($defaultproperties, $eventproperties));
95     }
97     /**
98      * You can't create a quiz module event when the module doesn't exist.
99      */
100     public function test_mod_quiz_core_calendar_validate_event_timestart_no_activity() {
101         global $CFG;
103         $this->resetAfterTest(true);
104         $this->setAdminUser();
105         $generator = $this->getDataGenerator();
106         $course = $generator->create_course();
108         $event = new \calendar_event([
109             'name' => 'Test event',
110             'description' => '',
111             'format' => 1,
112             'courseid' => $course->id,
113             'groupid' => 0,
114             'userid' => 2,
115             'modulename' => 'quiz',
116             'instance' => 1234,
117             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
118             'timestart' => time(),
119             'timeduration' => 86400,
120             'visible' => 1
121         ]);
123         $this->expectException('moodle_exception');
124         mod_quiz_core_calendar_validate_event_timestart($event);
125     }
127     /**
128      * A QUIZ_EVENT_TYPE_OPEN must be before the close time of the quiz activity.
129      */
130     public function test_mod_quiz_core_calendar_validate_event_timestart_valid_open_event() {
131         global $DB;
133         $this->resetAfterTest(true);
134         $this->setAdminUser();
135         $timeopen = time();
136         $timeclose = $timeopen + DAYSECS;
137         $quiz = $this->create_quiz_instance(['timeopen' => $timeopen, 'timeclose' => $timeclose]);
138         $event = $this->create_quiz_calendar_event($quiz, [
139             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
140             'timestart' => $timeopen
141         ]);
143         mod_quiz_core_calendar_validate_event_timestart($event);
144         // The function above will throw an exception if the event is
145         // invalid.
146         $this->assertTrue(true);
147     }
149     /**
150      * A QUIZ_EVENT_TYPE_OPEN can not have a start time set after the close time
151      * of the quiz activity.
152      */
153     public function test_mod_quiz_core_calendar_validate_event_timestart_invalid_open_event() {
154         global $DB;
156         $this->resetAfterTest(true);
157         $this->setAdminUser();
158         $timeopen = time();
159         $timeclose = $timeopen + DAYSECS;
160         $quiz = $this->create_quiz_instance(['timeopen' => $timeopen, 'timeclose' => $timeclose]);
161         $event = $this->create_quiz_calendar_event($quiz, [
162             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
163             'timestart' => $timeclose + 1
164         ]);
166         $this->expectException('moodle_exception');
167         mod_quiz_core_calendar_validate_event_timestart($event);
168     }
170     /**
171      * A QUIZ_EVENT_TYPE_CLOSE must be after the open time of the quiz activity.
172      */
173     public function test_mod_quiz_core_calendar_validate_event_timestart_valid_close_event() {
174         global $DB;
176         $this->resetAfterTest(true);
177         $this->setAdminUser();
178         $timeopen = time();
179         $timeclose = $timeopen + DAYSECS;
180         $quiz = $this->create_quiz_instance(['timeopen' => $timeopen, 'timeclose' => $timeclose]);
181         $event = $this->create_quiz_calendar_event($quiz, [
182             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
183             'timestart' => $timeclose
184         ]);
186         mod_quiz_core_calendar_validate_event_timestart($event);
187         // The function above will throw an exception if the event isn't
188         // valid.
189         $this->assertTrue(true);
190     }
192     /**
193      * A QUIZ_EVENT_TYPE_CLOSE can not have a start time set before the open time
194      * of the quiz activity.
195      */
196     public function test_mod_quiz_core_calendar_validate_event_timestart_invalid_close_event() {
197         global $DB;
199         $this->resetAfterTest(true);
200         $this->setAdminUser();
201         $timeopen = time();
202         $timeclose = $timeopen + DAYSECS;
203         $quiz = $this->create_quiz_instance(['timeopen' => $timeopen, 'timeclose' => $timeclose]);
204         $event = $this->create_quiz_calendar_event($quiz, [
205             'eventtype' => QUIZ_EVENT_TYPE_CLOSE,
206             'timestart' => $timeopen - 1
207         ]);
209         $this->expectException('moodle_exception');
210         mod_quiz_core_calendar_validate_event_timestart($event);
211     }
213     /**
214      * An unkown event type should not change the quiz instance.
215      */
216     public function test_mod_quiz_core_calendar_event_timestart_updated_unknown_event() {
217         global $DB;
219         $this->resetAfterTest(true);
220         $this->setAdminUser();
221         $timeopen = time();
222         $timeclose = $timeopen + DAYSECS;
223         $quiz = $this->create_quiz_instance(['timeopen' => $timeopen, 'timeclose' => $timeclose]);
224         $event = $this->create_quiz_calendar_event($quiz, [
225             'eventtype' => QUIZ_EVENT_TYPE_OPEN . "SOMETHING ELSE",
226             'timestart' => 1
227         ]);
229         mod_quiz_core_calendar_event_timestart_updated($event);
231         $quiz = $DB->get_record('quiz', ['id' => $quiz->id]);
232         $this->assertEquals($timeopen, $quiz->timeopen);
233         $this->assertEquals($timeclose, $quiz->timeclose);
234     }
236     /**
237      * A QUIZ_EVENT_TYPE_OPEN event should update the timeopen property of
238      * the quiz activity.
239      */
240     public function test_mod_quiz_core_calendar_event_timestart_updated_open_event() {
241         global $DB;
243         $this->resetAfterTest(true);
244         $this->setAdminUser();
245         $timeopen = time();
246         $timeclose = $timeopen + DAYSECS;
247         $timemodified = 1;
248         $newtimeopen = $timeopen - DAYSECS;
249         $quiz = $this->create_quiz_instance([
250             'timeopen' => $timeopen,
251             'timeclose' => $timeclose,
252             'timemodified' => $timemodified
253         ]);
254         $event = $this->create_quiz_calendar_event($quiz, [
255             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
256             'timestart' => $newtimeopen
257         ]);
259         mod_quiz_core_calendar_event_timestart_updated($event);
261         $quiz = $DB->get_record('quiz', ['id' => $quiz->id]);
262         // Ensure the timeopen property matches the event timestart.
263         $this->assertEquals($newtimeopen, $quiz->timeopen);
264         // Ensure the timeclose isn't changed.
265         $this->assertEquals($timeclose, $quiz->timeclose);
266         // Ensure the timemodified property has been changed.
267         $this->assertNotEquals($timemodified, $quiz->timemodified);
268     }
270     /**
271      * A QUIZ_EVENT_TYPE_CLOSE event should update the timeclose property of
272      * the quiz activity.
273      */
274     public function test_mod_quiz_core_calendar_event_timestart_updated_close_event() {
275         global $DB;
277         $this->resetAfterTest(true);
278         $this->setAdminUser();
279         $timeopen = time();
280         $timeclose = $timeopen + DAYSECS;
281         $timemodified = 1;
282         $newtimeclose = $timeclose + DAYSECS;
283         $quiz = $this->create_quiz_instance([
284             'timeopen' => $timeopen,
285             'timeclose' => $timeclose,
286             'timemodified' => $timemodified
287         ]);
288         $event = $this->create_quiz_calendar_event($quiz, [
289             'eventtype' => QUIZ_EVENT_TYPE_CLOSE,
290             'timestart' => $newtimeclose
291         ]);
293         mod_quiz_core_calendar_event_timestart_updated($event);
295         $quiz = $DB->get_record('quiz', ['id' => $quiz->id]);
296         // Ensure the timeclose property matches the event timestart.
297         $this->assertEquals($newtimeclose, $quiz->timeclose);
298         // Ensure the timeopen isn't changed.
299         $this->assertEquals($timeopen, $quiz->timeopen);
300         // Ensure the timemodified property has been changed.
301         $this->assertNotEquals($timemodified, $quiz->timemodified);
302     }
304     /**
305      * A QUIZ_EVENT_TYPE_OPEN event should not update the timeopen property of
306      * the quiz activity if it's an override.
307      */
308     public function test_mod_quiz_core_calendar_event_timestart_updated_open_event_override() {
309         global $DB;
311         $this->resetAfterTest(true);
312         $this->setAdminUser();
313         $user = $this->getDataGenerator()->create_user();
314         $timeopen = time();
315         $timeclose = $timeopen + DAYSECS;
316         $timemodified = 1;
317         $newtimeopen = $timeopen - DAYSECS;
318         $quiz = $this->create_quiz_instance([
319             'timeopen' => $timeopen,
320             'timeclose' => $timeclose,
321             'timemodified' => $timemodified
322         ]);
323         $event = $this->create_quiz_calendar_event($quiz, [
324             'userid' => $user->id,
325             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
326             'timestart' => $newtimeopen
327         ]);
328         $record = (object) [
329             'quiz' => $quiz->id,
330             'userid' => $user->id
331         ];
333         $DB->insert_record('quiz_overrides', $record);
335         mod_quiz_core_calendar_event_timestart_updated($event);
337         $quiz = $DB->get_record('quiz', ['id' => $quiz->id]);
338         // Ensure the timeopen property doesn't change.
339         $this->assertEquals($timeopen, $quiz->timeopen);
340         // Ensure the timeclose isn't changed.
341         $this->assertEquals($timeclose, $quiz->timeclose);
342         // Ensure the timemodified property has not been changed.
343         $this->assertEquals($timemodified, $quiz->timemodified);
344     }
346     /**
347      * If a student somehow finds a way to update the quiz calendar event
348      * then the callback should not update the quiz activity otherwise that
349      * would be a security issue.
350      */
351     public function test_student_role_cant_update_quiz_activity() {
352         global $DB;
354         $this->resetAfterTest();
355         $this->setAdminUser();
357         $generator = $this->getDataGenerator();
358         $user = $generator->create_user();
359         $course = $generator->create_course();
360         $context = context_course::instance($course->id);
361         $roleid = $generator->create_role();
362         $now = time();
363         $timeopen = (new DateTime())->setTimestamp($now);
364         $newtimeopen = (new DateTime())->setTimestamp($now)->modify('+1 day');
365         $quiz = $this->create_quiz_instance([
366             'course' => $course->id,
367             'timeopen' => $timeopen->getTimestamp()
368         ]);
370         $generator->enrol_user($user->id, $course->id, 'student');
371         $generator->role_assign($roleid, $user->id, $context->id);
373         $event = $this->create_quiz_calendar_event($quiz, [
374             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
375             'timestart' => $timeopen->getTimestamp()
376         ]);
378         assign_capability('moodle/course:manageactivities', CAP_PROHIBIT, $roleid, $context, true);
380         $this->setUser($user);
382         mod_quiz_core_calendar_event_timestart_updated($event);
384         $newquiz = $DB->get_record('quiz', ['id' => $quiz->id]);
385         // The time open shouldn't have changed even though we updated the calendar
386         // event.
387         $this->assertEquals($timeopen->getTimestamp(), $newquiz->timeopen);
388     }
390     /**
391      * A teacher with the capability to modify a quiz module should be
392      * able to update the quiz activity dates by changing the calendar
393      * event.
394      */
395     public function test_teacher_role_can_update_quiz_activity() {
396         global $DB;
398         $this->resetAfterTest();
399         $this->setAdminUser();
401         $generator = $this->getDataGenerator();
402         $user = $generator->create_user();
403         $course = $generator->create_course();
404         $context = context_course::instance($course->id);
405         $roleid = $generator->create_role();
406         $now = time();
407         $timeopen = (new DateTime())->setTimestamp($now);
408         $newtimeopen = (new DateTime())->setTimestamp($now)->modify('+1 day');
409         $quiz = $this->create_quiz_instance([
410             'course' => $course->id,
411             'timeopen' => $timeopen->getTimestamp()
412         ]);
414         $generator->enrol_user($user->id, $course->id, 'teacher');
415         $generator->role_assign($roleid, $user->id, $context->id);
417         $event = $this->create_quiz_calendar_event($quiz, [
418             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
419             'timestart' => $newtimeopen->getTimestamp()
420         ]);
422         assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleid, $context, true);
424         $this->setUser($user);
426         // Trigger and capture the event.
427         $sink = $this->redirectEvents();
429         mod_quiz_core_calendar_event_timestart_updated($event);
431         $triggeredevents = $sink->get_events();
432         $moduleupdatedevents = array_filter($triggeredevents, function($e) {
433             return is_a($e, 'core\event\course_module_updated');
434         });
436         $newquiz = $DB->get_record('quiz', ['id' => $quiz->id]);
437         // The should be updated along with the event because the user has sufficient
438         // capabilities.
439         $this->assertEquals($newtimeopen->getTimestamp(), $newquiz->timeopen);
440         // Confirm that a module updated event is fired when the module
441         // is changed.
442         $this->assertNotEmpty($moduleupdatedevents);
443     }
446     /**
447      * An unkown event type should not have any limits
448      */
449     public function test_mod_quiz_core_calendar_get_valid_event_timestart_range_unknown_event() {
450         global $DB;
452         $this->resetAfterTest(true);
453         $this->setAdminUser();
454         $timeopen = time();
455         $timeclose = $timeopen + DAYSECS;
456         $quiz = $this->create_quiz_instance([
457             'timeopen' => $timeopen,
458             'timeclose' => $timeclose
459         ]);
460         $event = $this->create_quiz_calendar_event($quiz, [
461             'eventtype' => QUIZ_EVENT_TYPE_OPEN . "SOMETHING ELSE",
462             'timestart' => 1
463         ]);
465         list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz);
466         $this->assertNull($min);
467         $this->assertNull($max);
468     }
470     /**
471      * The open event should be limited by the quiz's timeclose property, if it's set.
472      */
473     public function test_mod_quiz_core_calendar_get_valid_event_timestart_range_open_event() {
474         global $DB;
476         $this->resetAfterTest(true);
477         $this->setAdminUser();
478         $timeopen = time();
479         $timeclose = $timeopen + DAYSECS;
480         $quiz = $this->create_quiz_instance([
481             'timeopen' => $timeopen,
482             'timeclose' => $timeclose
483         ]);
484         $event = $this->create_quiz_calendar_event($quiz, [
485             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
486             'timestart' => 1
487         ]);
489         // The max limit should be bounded by the timeclose value.
490         list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz);
492         $this->assertNull($min);
493         $this->assertEquals($timeclose, $max[0]);
495         // No timeclose value should result in no upper limit.
496         $quiz->timeclose = 0;
497         list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz);
499         $this->assertNull($min);
500         $this->assertNull($max);
501     }
503     /**
504      * An override event should not have any limits.
505      */
506     public function test_mod_quiz_core_calendar_get_valid_event_timestart_range_override_event() {
507         global $DB;
509         $this->resetAfterTest(true);
510         $this->setAdminUser();
511         $generator = $this->getDataGenerator();
512         $user = $generator->create_user();
513         $course = $generator->create_course();
514         $timeopen = time();
515         $timeclose = $timeopen + DAYSECS;
516         $quiz = $this->create_quiz_instance([
517             'course' => $course->id,
518             'timeopen' => $timeopen,
519             'timeclose' => $timeclose
520         ]);
521         $event = $this->create_quiz_calendar_event($quiz, [
522             'userid' => $user->id,
523             'eventtype' => QUIZ_EVENT_TYPE_OPEN,
524             'timestart' => 1
525         ]);
526         $record = (object) [
527             'quiz' => $quiz->id,
528             'userid' => $user->id
529         ];
531         $DB->insert_record('quiz_overrides', $record);
533         list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz);
535         $this->assertNull($min);
536         $this->assertNull($max);
537     }
539     /**
540      * The close event should be limited by the quiz's timeopen property, if it's set.
541      */
542     public function test_mod_quiz_core_calendar_get_valid_event_timestart_range_close_event() {
543         global $DB;
545         $this->resetAfterTest(true);
546         $this->setAdminUser();
547         $timeopen = time();
548         $timeclose = $timeopen + DAYSECS;
549         $quiz = $this->create_quiz_instance([
550             'timeopen' => $timeopen,
551             'timeclose' => $timeclose
552         ]);
553         $event = $this->create_quiz_calendar_event($quiz, [
554             'eventtype' => QUIZ_EVENT_TYPE_CLOSE,
555             'timestart' => 1,
556         ]);
558         // The max limit should be bounded by the timeclose value.
559         list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz);
561         $this->assertEquals($timeopen, $min[0]);
562         $this->assertNull($max);
564         // No timeclose value should result in no upper limit.
565         $quiz->timeopen = 0;
566         list ($min, $max) = mod_quiz_core_calendar_get_valid_event_timestart_range($event, $quiz);
568         $this->assertNull($min);
569         $this->assertNull($max);
570     }
572     /**
573      * When the close date event is changed and it results in the time close value of
574      * the quiz being updated then the open quiz attempts should also be updated.
575      */
576     public function test_core_calendar_event_timestart_updated_update_quiz_attempt() {
577         global $DB;
579         $this->resetAfterTest();
580         $this->setAdminUser();
582         $generator = $this->getDataGenerator();
583         $teacher = $generator->create_user();
584         $student = $generator->create_user();
585         $course = $generator->create_course();
586         $context = context_course::instance($course->id);
587         $roleid = $generator->create_role();
588         $now = time();
589         $timelimit = 600;
590         $timeopen = (new DateTime())->setTimestamp($now);
591         $timeclose = (new DateTime())->setTimestamp($now)->modify('+1 day');
592         // The new close time being earlier than the time open + time limit should
593         // result in an update to the quiz attempts.
594         $newtimeclose = $timeopen->getTimestamp() + $timelimit - 10;
595         $quiz = $this->create_quiz_instance([
596             'course' => $course->id,
597             'timeopen' => $timeopen->getTimestamp(),
598             'timeclose' => $timeclose->getTimestamp(),
599             'timelimit' => $timelimit
600         ]);
602         $generator->enrol_user($student->id, $course->id, 'student');
603         $generator->enrol_user($teacher->id, $course->id, 'teacher');
604         $generator->role_assign($roleid, $teacher->id, $context->id);
606         $event = $this->create_quiz_calendar_event($quiz, [
607             'eventtype' => QUIZ_EVENT_TYPE_CLOSE,
608             'timestart' => $newtimeclose
609         ]);
611         assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleid, $context, true);
613         $attemptid = $DB->insert_record(
614             'quiz_attempts',
615             [
616                 'quiz' => $quiz->id,
617                 'userid' => $student->id,
618                 'state' => 'inprogress',
619                 'timestart' => $timeopen->getTimestamp(),
620                 'timecheckstate' => 0,
621                 'layout' => '',
622                 'uniqueid' => 1
623             ]
624         );
626         $this->setUser($teacher);
628         mod_quiz_core_calendar_event_timestart_updated($event);
630         $quiz = $DB->get_record('quiz', ['id' => $quiz->id]);
631         $attempt = $DB->get_record('quiz_attempts', ['id' => $attemptid]);
632         // When the close date is changed so that it's earlier than the time open
633         // plus the time limit of the quiz then the attempt's timecheckstate should
634         // be updated to the new time close date of the quiz.
635         $this->assertEquals($newtimeclose, $attempt->timecheckstate);
636         $this->assertEquals($newtimeclose, $quiz->timeclose);
637     }