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/>.
16
17/**
18 * Completion tests.
19 *
20 * @package    core_completion
21 * @category   phpunit
22 * @copyright  2008 Sam Marshall
23 * @copyright  2013 Frédéric Massart
24 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
27defined('MOODLE_INTERNAL') || die();
28
29global $CFG;
30require_once($CFG->libdir.'/completionlib.php');
31
32class core_completionlib_testcase extends advanced_testcase {
33    protected $course;
34    protected $user;
35    protected $module1;
36    protected $module2;
37
38    protected function mock_setup() {
39        global $DB, $CFG, $USER;
40
41        $this->resetAfterTest();
42
43        $DB = $this->createMock(get_class($DB));
44        $CFG->enablecompletion = COMPLETION_ENABLED;
45        $USER = (object)array('id' =>314159);
46    }
47
48    /**
49     * Create course with user and activities.
50     */
51    protected function setup_data() {
52        global $DB, $CFG;
53
54        $this->resetAfterTest();
55
56        // Enable completion before creating modules, otherwise the completion data is not written in DB.
57        $CFG->enablecompletion = true;
58
59        // Create a course with activities.
60        $this->course = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
61        $this->user = $this->getDataGenerator()->create_user();
62        $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id);
63
64        $this->module1 = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id));
65        $this->module2 = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id));
66    }
67
68    /**
69     * Asserts that two variables are equal.
70     *
71     * @param  mixed   $expected
72     * @param  mixed   $actual
73     * @param  string  $message
74     * @param  float   $delta
75     * @param  integer $maxDepth
76     * @param  boolean $canonicalize
77     * @param  boolean $ignoreCase
78     */
79    public static function assertEquals($expected, $actual, string $message = '', float $delta = 0, int $maxDepth = 10,
80                                        bool $canonicalize = false, bool $ignoreCase = false): void {
81        // Nasty cheating hack: prevent random failures on timemodified field.
82        if (is_object($expected) and is_object($actual)) {
83            if (property_exists($expected, 'timemodified') and property_exists($actual, 'timemodified')) {
84                if ($expected->timemodified + 1 == $actual->timemodified) {
85                    $expected = clone($expected);
86                    $expected->timemodified = $actual->timemodified;
87                }
88            }
89        }
90        parent::assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
91    }
92
93    public function test_is_enabled() {
94        global $CFG;
95        $this->mock_setup();
96
97        // Config alone.
98        $CFG->enablecompletion = COMPLETION_DISABLED;
99        $this->assertEquals(COMPLETION_DISABLED, completion_info::is_enabled_for_site());
100        $CFG->enablecompletion = COMPLETION_ENABLED;
101        $this->assertEquals(COMPLETION_ENABLED, completion_info::is_enabled_for_site());
102
103        // Course.
104        $course = (object)array('id' =>13);
105        $c = new completion_info($course);
106        $course->enablecompletion = COMPLETION_DISABLED;
107        $this->assertEquals(COMPLETION_DISABLED, $c->is_enabled());
108        $course->enablecompletion = COMPLETION_ENABLED;
109        $this->assertEquals(COMPLETION_ENABLED, $c->is_enabled());
110        $CFG->enablecompletion = COMPLETION_DISABLED;
111        $this->assertEquals(COMPLETION_DISABLED, $c->is_enabled());
112
113        // Course and CM.
114        $cm = new stdClass();
115        $cm->completion = COMPLETION_TRACKING_MANUAL;
116        $this->assertEquals(COMPLETION_DISABLED, $c->is_enabled($cm));
117        $CFG->enablecompletion = COMPLETION_ENABLED;
118        $course->enablecompletion = COMPLETION_DISABLED;
119        $this->assertEquals(COMPLETION_DISABLED, $c->is_enabled($cm));
120        $course->enablecompletion = COMPLETION_ENABLED;
121        $this->assertEquals(COMPLETION_TRACKING_MANUAL, $c->is_enabled($cm));
122        $cm->completion = COMPLETION_TRACKING_NONE;
123        $this->assertEquals(COMPLETION_TRACKING_NONE, $c->is_enabled($cm));
124        $cm->completion = COMPLETION_TRACKING_AUTOMATIC;
125        $this->assertEquals(COMPLETION_TRACKING_AUTOMATIC, $c->is_enabled($cm));
126    }
127
128    public function test_update_state() {
129        $this->mock_setup();
130
131        $mockbuilder = $this->getMockBuilder('completion_info');
132        $mockbuilder->setMethods(array('is_enabled', 'get_data', 'internal_get_state', 'internal_set_data',
133                                       'user_can_override_completion'));
134        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
135        $c = $mockbuilder->getMock();
136        $cm = (object)array('id'=>13, 'course'=>42);
137
138        // Not enabled, should do nothing.
139        $c->expects($this->at(0))
140            ->method('is_enabled')
141            ->with($cm)
142            ->will($this->returnValue(false));
143        $c->update_state($cm);
144
145        // Enabled, but current state is same as possible result, do nothing.
146        $current = (object)array('completionstate' => COMPLETION_COMPLETE, 'overrideby' => null);
147        $c->expects($this->at(0))
148            ->method('is_enabled')
149            ->with($cm)
150            ->will($this->returnValue(true));
151        $c->expects($this->at(1))
152            ->method('get_data')
153            ->with($cm, false, 0)
154            ->will($this->returnValue($current));
155        $c->update_state($cm, COMPLETION_COMPLETE);
156
157        // Enabled, but current state is a specific one and new state is just
158        // complete, so do nothing.
159        $current->completionstate = COMPLETION_COMPLETE_PASS;
160        $c->expects($this->at(0))
161            ->method('is_enabled')
162            ->with($cm)
163            ->will($this->returnValue(true));
164        $c->expects($this->at(1))
165            ->method('get_data')
166            ->with($cm, false, 0)
167            ->will($this->returnValue($current));
168        $c->update_state($cm, COMPLETION_COMPLETE);
169
170        // Manual, change state (no change).
171        $cm = (object)array('id'=>13, 'course'=>42, 'completion'=>COMPLETION_TRACKING_MANUAL);
172        $current->completionstate=COMPLETION_COMPLETE;
173        $c->expects($this->at(0))
174            ->method('is_enabled')
175            ->with($cm)
176            ->will($this->returnValue(true));
177        $c->expects($this->at(1))
178            ->method('get_data')
179            ->with($cm, false, 0)
180            ->will($this->returnValue($current));
181        $c->update_state($cm, COMPLETION_COMPLETE);
182
183        // Manual, change state (change).
184        $c->expects($this->at(0))
185            ->method('is_enabled')
186            ->with($cm)
187            ->will($this->returnValue(true));
188        $c->expects($this->at(1))
189            ->method('get_data')
190            ->with($cm, false, 0)
191            ->will($this->returnValue($current));
192        $changed = clone($current);
193        $changed->timemodified = time();
194        $changed->completionstate = COMPLETION_INCOMPLETE;
195        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
196        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
197        $c->expects($this->at(2))
198            ->method('internal_set_data')
199            ->with($cm, $comparewith);
200        $c->update_state($cm, COMPLETION_INCOMPLETE);
201
202        // Auto, change state.
203        $cm = (object)array('id'=>13, 'course'=>42, 'completion'=>COMPLETION_TRACKING_AUTOMATIC);
204        $current = (object)array('completionstate' => COMPLETION_COMPLETE, 'overrideby' => null);
205        $c->expects($this->at(0))
206            ->method('is_enabled')
207            ->with($cm)
208            ->will($this->returnValue(true));
209        $c->expects($this->at(1))
210            ->method('get_data')
211            ->with($cm, false, 0)
212            ->will($this->returnValue($current));
213        $c->expects($this->at(2))
214            ->method('internal_get_state')
215            ->will($this->returnValue(COMPLETION_COMPLETE_PASS));
216        $changed = clone($current);
217        $changed->timemodified = time();
218        $changed->completionstate = COMPLETION_COMPLETE_PASS;
219        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
220        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
221        $c->expects($this->at(3))
222            ->method('internal_set_data')
223            ->with($cm, $comparewith);
224        $c->update_state($cm, COMPLETION_COMPLETE_PASS);
225
226        // Manual tracking, change state by overriding it manually.
227        $cm = (object)array('id' => 13, 'course' => 42, 'completion' => COMPLETION_TRACKING_MANUAL);
228        $current = (object)array('completionstate' => COMPLETION_INCOMPLETE, 'overrideby' => null);
229        $c->expects($this->at(0))
230            ->method('is_enabled')
231            ->with($cm)
232            ->will($this->returnValue(true));
233        $c->expects($this->at(1)) // Pretend the user has the required capability for overriding completion statuses.
234            ->method('user_can_override_completion')
235            ->will($this->returnValue(true));
236        $c->expects($this->at(2))
237            ->method('get_data')
238            ->with($cm, false, 100)
239            ->will($this->returnValue($current));
240        $changed = clone($current);
241        $changed->timemodified = time();
242        $changed->completionstate = COMPLETION_COMPLETE;
243        $changed->overrideby = 314159;
244        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
245        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
246        $c->expects($this->at(3))
247            ->method('internal_set_data')
248            ->with($cm, $comparewith);
249        $c->update_state($cm, COMPLETION_COMPLETE, 100, true);
250        // And confirm that the status can be changed back to incomplete without an override.
251        $c->update_state($cm, COMPLETION_INCOMPLETE, 100);
252        $c->expects($this->at(0))
253            ->method('get_data')
254            ->with($cm, false, 100)
255            ->will($this->returnValue($current));
256        $c->get_data($cm, false, 100);
257
258        // Auto, change state via override, incomplete to complete.
259        $cm = (object)array('id' => 13, 'course' => 42, 'completion' => COMPLETION_TRACKING_AUTOMATIC);
260        $current = (object)array('completionstate' => COMPLETION_INCOMPLETE, 'overrideby' => null);
261        $c->expects($this->at(0))
262            ->method('is_enabled')
263            ->with($cm)
264            ->will($this->returnValue(true));
265        $c->expects($this->at(1)) // Pretend the user has the required capability for overriding completion statuses.
266            ->method('user_can_override_completion')
267            ->will($this->returnValue(true));
268        $c->expects($this->at(2))
269            ->method('get_data')
270            ->with($cm, false, 100)
271            ->will($this->returnValue($current));
272        $changed = clone($current);
273        $changed->timemodified = time();
274        $changed->completionstate = COMPLETION_COMPLETE;
275        $changed->overrideby = 314159;
276        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
277        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
278        $c->expects($this->at(3))
279            ->method('internal_set_data')
280            ->with($cm, $comparewith);
281        $c->update_state($cm, COMPLETION_COMPLETE, 100, true);
282        $c->expects($this->at(0))
283            ->method('get_data')
284            ->with($cm, false, 100)
285            ->will($this->returnValue($changed));
286        $c->get_data($cm, false, 100);
287
288        // Now confirm that the status cannot be changed back to incomplete without an override.
289        // I.e. test that automatic completion won't trigger a change back to COMPLETION_INCOMPLETE when overridden.
290        $c->update_state($cm, COMPLETION_INCOMPLETE, 100);
291        $c->expects($this->at(0))
292            ->method('get_data')
293            ->with($cm, false, 100)
294            ->will($this->returnValue($changed));
295        $c->get_data($cm, false, 100);
296
297        // Now confirm the status can be changed back from complete to incomplete using an override.
298        $cm = (object)array('id' => 13, 'course' => 42, 'completion' => COMPLETION_TRACKING_AUTOMATIC);
299        $current = (object)array('completionstate' => COMPLETION_COMPLETE, 'overrideby' => 2);
300        $c->expects($this->at(0))
301            ->method('is_enabled')
302            ->with($cm)
303            ->will($this->returnValue(true));
304        $c->expects($this->at(1)) // Pretend the user has the required capability for overriding completion statuses.
305        ->method('user_can_override_completion')
306            ->will($this->returnValue(true));
307        $c->expects($this->at(2))
308            ->method('get_data')
309            ->with($cm, false, 100)
310            ->will($this->returnValue($current));
311        $changed = clone($current);
312        $changed->timemodified = time();
313        $changed->completionstate = COMPLETION_INCOMPLETE;
314        $changed->overrideby = 314159;
315        $comparewith = new phpunit_constraint_object_is_equal_with_exceptions($changed);
316        $comparewith->add_exception('timemodified', 'assertGreaterThanOrEqual');
317        $c->expects($this->at(3))
318            ->method('internal_set_data')
319            ->with($cm, $comparewith);
320        $c->update_state($cm, COMPLETION_INCOMPLETE, 100, true);
321        $c->expects($this->at(0))
322            ->method('get_data')
323            ->with($cm, false, 100)
324            ->will($this->returnValue($changed));
325        $c->get_data($cm, false, 100);
326    }
327
328    public function test_internal_get_state() {
329        global $DB;
330        $this->mock_setup();
331
332        $mockbuilder = $this->getMockBuilder('completion_info');
333        $mockbuilder->setMethods(array('internal_get_grade_state'));
334        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
335        $c = $mockbuilder->getMock();
336
337        $cm = (object)array('id'=>13, 'course'=>42, 'completiongradeitemnumber'=>null);
338
339        // If view is required, but they haven't viewed it yet.
340        $cm->completionview = COMPLETION_VIEW_REQUIRED;
341        $current = (object)array('viewed'=>COMPLETION_NOT_VIEWED);
342        $this->assertEquals(COMPLETION_INCOMPLETE, $c->internal_get_state($cm, 123, $current));
343
344        // OK set view not required.
345        $cm->completionview = COMPLETION_VIEW_NOT_REQUIRED;
346
347        // Test not getting module name.
348        $cm->modname='label';
349        $this->assertEquals(COMPLETION_COMPLETE, $c->internal_get_state($cm, 123, $current));
350
351        // Test getting module name.
352        $cm->module = 13;
353        unset($cm->modname);
354        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
355        $DB->expects($this->once())
356            ->method('get_field')
357            ->with('modules', 'name', array('id'=>13))
358            ->will($this->returnValue('lable'));
359        $this->assertEquals(COMPLETION_COMPLETE, $c->internal_get_state($cm, 123, $current));
360
361        // Note: This function is not fully tested (including kind of the main part) because:
362        // * the grade_item/grade_grade calls are static and can't be mocked,
363        // * the plugin_supports call is static and can't be mocked.
364    }
365
366    public function test_set_module_viewed() {
367        $this->mock_setup();
368
369        $mockbuilder = $this->getMockBuilder('completion_info');
370        $mockbuilder->setMethods(array('is_enabled', 'get_data', 'internal_set_data', 'update_state'));
371        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
372        $c = $mockbuilder->getMock();
373        $cm = (object)array('id'=>13, 'course'=>42);
374
375        // Not tracking completion, should do nothing.
376        $cm->completionview = COMPLETION_VIEW_NOT_REQUIRED;
377        $c->set_module_viewed($cm);
378
379        // Tracking completion but completion is disabled, should do nothing.
380        $cm->completionview = COMPLETION_VIEW_REQUIRED;
381        $c->expects($this->at(0))
382            ->method('is_enabled')
383            ->with($cm)
384            ->will($this->returnValue(false));
385        $c->set_module_viewed($cm);
386
387        // Now it's enabled, we expect it to get data. If data already has
388        // viewed, still do nothing.
389        $c->expects($this->at(0))
390            ->method('is_enabled')
391            ->with($cm)
392            ->will($this->returnValue(true));
393        $c->expects($this->at(1))
394            ->method('get_data')
395            ->with($cm, 0)
396            ->will($this->returnValue((object)array('viewed'=>COMPLETION_VIEWED)));
397        $c->set_module_viewed($cm);
398
399        // OK finally one that hasn't been viewed, now it should set it viewed
400        // and update state.
401        $c->expects($this->at(0))
402            ->method('is_enabled')
403            ->with($cm)
404            ->will($this->returnValue(true));
405        $c->expects($this->at(1))
406            ->method('get_data')
407            ->with($cm, false, 1337)
408            ->will($this->returnValue((object)array('viewed'=>COMPLETION_NOT_VIEWED)));
409        $c->expects($this->at(2))
410            ->method('internal_set_data')
411            ->with($cm, (object)array('viewed'=>COMPLETION_VIEWED));
412        $c->expects($this->at(3))
413            ->method('update_state')
414            ->with($cm, COMPLETION_COMPLETE, 1337);
415        $c->set_module_viewed($cm, 1337);
416    }
417
418    public function test_count_user_data() {
419        global $DB;
420        $this->mock_setup();
421
422        $course = (object)array('id'=>13);
423        $cm = (object)array('id'=>42);
424
425        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
426        $DB->expects($this->at(0))
427            ->method('get_field_sql')
428            ->will($this->returnValue(666));
429
430        $c = new completion_info($course);
431        $this->assertEquals(666, $c->count_user_data($cm));
432    }
433
434    public function test_delete_all_state() {
435        global $DB;
436        $this->mock_setup();
437
438        $course = (object)array('id'=>13);
439        $cm = (object)array('id'=>42, 'course'=>13);
440        $c = new completion_info($course);
441
442        // Check it works ok without data in session.
443        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
444        $DB->expects($this->at(0))
445            ->method('delete_records')
446            ->with('course_modules_completion', array('coursemoduleid'=>42))
447            ->will($this->returnValue(true));
448        $c->delete_all_state($cm);
449    }
450
451    public function test_reset_all_state() {
452        global $DB;
453        $this->mock_setup();
454
455        $mockbuilder = $this->getMockBuilder('completion_info');
456        $mockbuilder->setMethods(array('delete_all_state', 'get_tracked_users', 'update_state'));
457        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
458        $c = $mockbuilder->getMock();
459
460        $cm = (object)array('id'=>13, 'course'=>42, 'completion'=>COMPLETION_TRACKING_AUTOMATIC);
461
462        /** @var $DB PHPUnit_Framework_MockObject_MockObject */
463        $DB->expects($this->at(0))
464            ->method('get_recordset')
465            ->will($this->returnValue(
466                new core_completionlib_fake_recordset(array((object)array('id'=>1, 'userid'=>100), (object)array('id'=>2, 'userid'=>101)))));
467
468        $c->expects($this->at(0))
469            ->method('delete_all_state')
470            ->with($cm);
471
472        $c->expects($this->at(1))
473            ->method('get_tracked_users')
474            ->will($this->returnValue(array(
475            (object)array('id'=>100, 'firstname'=>'Woot', 'lastname'=>'Plugh'),
476            (object)array('id'=>201, 'firstname'=>'Vroom', 'lastname'=>'Xyzzy'))));
477
478        $c->expects($this->at(2))
479            ->method('update_state')
480            ->with($cm, COMPLETION_UNKNOWN, 100);
481        $c->expects($this->at(3))
482            ->method('update_state')
483            ->with($cm, COMPLETION_UNKNOWN, 101);
484        $c->expects($this->at(4))
485            ->method('update_state')
486            ->with($cm, COMPLETION_UNKNOWN, 201);
487
488        $c->reset_all_state($cm);
489    }
490
491    /**
492     * Data provider for test_get_data().
493     *
494     * @return array[]
495     */
496    public function get_data_provider() {
497        return [
498            'No completion record' => [
499                false, true, false, COMPLETION_INCOMPLETE
500            ],
501            'Not completed' => [
502                false, true, true, COMPLETION_INCOMPLETE
503            ],
504            'Completed' => [
505                false, true, true, COMPLETION_COMPLETE
506            ],
507            'Whole course, complete' => [
508                true, true, true, COMPLETION_COMPLETE
509            ],
510            'Get data for another user, result should be not cached' => [
511                false, false, true,  COMPLETION_INCOMPLETE
512            ],
513            'Get data for another user, including whole course, result should be not cached' => [
514                true, false, true,  COMPLETION_INCOMPLETE
515            ],
516        ];
517    }
518
519    /**
520     * Tests for completion_info::get_data().
521     *
522     * @dataProvider get_data_provider
523     * @param bool $wholecourse Whole course parameter for get_data().
524     * @param bool $sameuser Whether the user calling get_data() is the user itself.
525     * @param bool $hasrecord Whether to create a course_modules_completion record.
526     * @param int $completion The completion state expected.
527     */
528    public function test_get_data(bool $wholecourse, bool $sameuser, bool $hasrecord, int $completion) {
529        global $DB;
530
531        $this->setup_data();
532        $user = $this->user;
533
534        /** @var \mod_choice_generator $choicegenerator */
535        $choicegenerator = $this->getDataGenerator()->get_plugin_generator('mod_choice');
536        $choice = $choicegenerator->create_instance([
537            'course' => $this->course->id,
538            'completion' => true,
539            'completionview' => true,
540        ]);
541
542        $cm = get_coursemodule_from_instance('choice', $choice->id);
543
544        // Let's manually create a course completion record instead of going thru the hoops to complete an activity.
545        if ($hasrecord) {
546            $cmcompletionrecord = (object)[
547                'coursemoduleid' => $cm->id,
548                'userid' => $user->id,
549                'completionstate' => $completion,
550                'viewed' => 0,
551                'overrideby' => null,
552                'timemodified' => 0,
553            ];
554            $DB->insert_record('course_modules_completion', $cmcompletionrecord);
555        }
556
557        // Whether we expect for the returned completion data to be stored in the cache.
558        $iscached = true;
559
560        if (!$sameuser) {
561            $iscached = false;
562            $this->setAdminUser();
563        } else {
564            $this->setUser($user);
565        }
566
567        // Mock other completion data.
568        $completioninfo = new completion_info($this->course);
569
570        $result = $completioninfo->get_data($cm, $wholecourse, $user->id);
571        // Course module ID of the returned completion data must match this activity's course module ID.
572        $this->assertEquals($cm->id, $result->coursemoduleid);
573        // User ID of the returned completion data must match the user's ID.
574        $this->assertEquals($user->id, $result->userid);
575        // The completion state of the returned completion data must match the expected completion state.
576        $this->assertEquals($completion, $result->completionstate);
577
578        // If the user has no completion record, then the default record should be returned.
579        if (!$hasrecord) {
580            $this->assertEquals(0, $result->id);
581        }
582
583        // Check caching.
584        $key = "{$user->id}_{$this->course->id}";
585        $cache = cache::make('core', 'completion');
586        if ($iscached) {
587            // If we expect this to be cached, then fetching the result must match the cached data.
588            $this->assertEquals($result, (object)$cache->get($key)[$cm->id]);
589
590            // Check cached data for other course modules in the course.
591            // The sample module created in setup_data() should suffice to confirm this.
592            $othercm = get_coursemodule_from_instance('forum', $this->module1->id);
593            if ($wholecourse) {
594                $this->assertArrayHasKey($othercm->id, $cache->get($key));
595            } else {
596                $this->assertArrayNotHasKey($othercm->id, $cache->get($key));
597            }
598        } else {
599            // Otherwise, this should not be cached.
600            $this->assertFalse($cache->get($key));
601        }
602    }
603
604    public function test_internal_set_data() {
605        global $DB;
606        $this->setup_data();
607
608        $this->setUser($this->user);
609        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
610        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id), $completionauto);
611        $cm = get_coursemodule_from_instance('forum', $forum->id);
612        $c = new completion_info($this->course);
613
614        // 1) Test with new data.
615        $data = new stdClass();
616        $data->id = 0;
617        $data->userid = $this->user->id;
618        $data->coursemoduleid = $cm->id;
619        $data->completionstate = COMPLETION_COMPLETE;
620        $data->timemodified = time();
621        $data->viewed = COMPLETION_NOT_VIEWED;
622        $data->overrideby = null;
623
624        $c->internal_set_data($cm, $data);
625        $d1 = $DB->get_field('course_modules_completion', 'id', array('coursemoduleid' => $cm->id));
626        $this->assertEquals($d1, $data->id);
627        $cache = cache::make('core', 'completion');
628        // Cache was not set for another user.
629        $this->assertEquals(array('cacherev' => $this->course->cacherev, $cm->id => $data),
630            $cache->get($data->userid . '_' . $cm->course));
631
632        // 2) Test with existing data and for different user.
633        $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id), $completionauto);
634        $cm2 = get_coursemodule_from_instance('forum', $forum2->id);
635        $newuser = $this->getDataGenerator()->create_user();
636
637        $d2 = new stdClass();
638        $d2->id = 7;
639        $d2->userid = $newuser->id;
640        $d2->coursemoduleid = $cm2->id;
641        $d2->completionstate = COMPLETION_COMPLETE;
642        $d2->timemodified = time();
643        $d2->viewed = COMPLETION_NOT_VIEWED;
644        $d2->overrideby = null;
645        $c->internal_set_data($cm2, $d2);
646        // Cache for current user returns the data.
647        $cachevalue = $cache->get($data->userid . '_' . $cm->course);
648        $this->assertEquals($data, $cachevalue[$cm->id]);
649        // Cache for another user is not filled.
650        $this->assertEquals(false, $cache->get($d2->userid . '_' . $cm2->course));
651
652        // 3) Test where it THINKS the data is new (from cache) but actually
653        //    in the database it has been set since.
654        // 1) Test with new data.
655        $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id), $completionauto);
656        $cm3 = get_coursemodule_from_instance('forum', $forum3->id);
657        $newuser2 = $this->getDataGenerator()->create_user();
658        $d3 = new stdClass();
659        $d3->id = 13;
660        $d3->userid = $newuser2->id;
661        $d3->coursemoduleid = $cm3->id;
662        $d3->completionstate = COMPLETION_COMPLETE;
663        $d3->timemodified = time();
664        $d3->viewed = COMPLETION_NOT_VIEWED;
665        $d3->overrideby = null;
666        $DB->insert_record('course_modules_completion', $d3);
667        $c->internal_set_data($cm, $data);
668    }
669
670    public function test_get_progress_all() {
671        global $DB;
672        $this->mock_setup();
673
674        $mockbuilder = $this->getMockBuilder('completion_info');
675        $mockbuilder->setMethods(array('get_tracked_users'));
676        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
677        $c = $mockbuilder->getMock();
678
679        // 1) Basic usage.
680        $c->expects($this->at(0))
681            ->method('get_tracked_users')
682            ->with(false,  array(),  0,  '',  '',  '',  null)
683            ->will($this->returnValue(array(
684                (object)array('id'=>100, 'firstname'=>'Woot', 'lastname'=>'Plugh'),
685                (object)array('id'=>201, 'firstname'=>'Vroom', 'lastname'=>'Xyzzy'))));
686        $DB->expects($this->at(0))
687            ->method('get_in_or_equal')
688            ->with(array(100, 201))
689            ->will($this->returnValue(array(' IN (100, 201)', array())));
690        $progress1 = (object)array('userid'=>100, 'coursemoduleid'=>13);
691        $progress2 = (object)array('userid'=>201, 'coursemoduleid'=>14);
692        $DB->expects($this->at(1))
693            ->method('get_recordset_sql')
694            ->will($this->returnValue(new core_completionlib_fake_recordset(array($progress1, $progress2))));
695
696        $this->assertEquals(array(
697                100 => (object)array('id'=>100, 'firstname'=>'Woot', 'lastname'=>'Plugh',
698                    'progress'=>array(13=>$progress1)),
699                201 => (object)array('id'=>201, 'firstname'=>'Vroom', 'lastname'=>'Xyzzy',
700                    'progress'=>array(14=>$progress2)),
701            ), $c->get_progress_all(false));
702
703        // 2) With more than 1, 000 results.
704        $tracked = array();
705        $ids = array();
706        $progress = array();
707        for ($i = 100; $i<2000; $i++) {
708            $tracked[] = (object)array('id'=>$i, 'firstname'=>'frog', 'lastname'=>$i);
709            $ids[] = $i;
710            $progress[] = (object)array('userid'=>$i, 'coursemoduleid'=>13);
711            $progress[] = (object)array('userid'=>$i, 'coursemoduleid'=>14);
712        }
713        $c->expects($this->at(0))
714            ->method('get_tracked_users')
715            ->with(true,  3,  0,  '',  '',  '',  null)
716            ->will($this->returnValue($tracked));
717        $DB->expects($this->at(0))
718            ->method('get_in_or_equal')
719            ->with(array_slice($ids, 0, 1000))
720            ->will($this->returnValue(array(' IN whatever', array())));
721        $DB->expects($this->at(1))
722            ->method('get_recordset_sql')
723            ->will($this->returnValue(new core_completionlib_fake_recordset(array_slice($progress, 0, 1000))));
724
725        $DB->expects($this->at(2))
726            ->method('get_in_or_equal')
727            ->with(array_slice($ids, 1000))
728            ->will($this->returnValue(array(' IN whatever2', array())));
729        $DB->expects($this->at(3))
730            ->method('get_recordset_sql')
731            ->will($this->returnValue(new core_completionlib_fake_recordset(array_slice($progress, 1000))));
732
733        $result = $c->get_progress_all(true, 3);
734        $resultok = true;
735        $resultok  =  $resultok && ($ids == array_keys($result));
736
737        foreach ($result as $userid => $data) {
738            $resultok  =  $resultok && $data->firstname == 'frog';
739            $resultok  =  $resultok && $data->lastname == $userid;
740            $resultok  =  $resultok && $data->id == $userid;
741            $cms = $data->progress;
742            $resultok =  $resultok && (array(13, 14) == array_keys($cms));
743            $resultok =  $resultok && ((object)array('userid'=>$userid, 'coursemoduleid'=>13) == $cms[13]);
744            $resultok =  $resultok && ((object)array('userid'=>$userid, 'coursemoduleid'=>14) == $cms[14]);
745        }
746        $this->assertTrue($resultok);
747    }
748
749    public function test_inform_grade_changed() {
750        $this->mock_setup();
751
752        $mockbuilder = $this->getMockBuilder('completion_info');
753        $mockbuilder->setMethods(array('is_enabled', 'update_state'));
754        $mockbuilder->setConstructorArgs(array((object)array('id' => 42)));
755        $c = $mockbuilder->getMock();
756
757        $cm = (object)array('course'=>42, 'id'=>13, 'completion'=>0, 'completiongradeitemnumber'=>null);
758        $item = (object)array('itemnumber'=>3,  'gradepass'=>1,  'hidden'=>0);
759        $grade = (object)array('userid'=>31337,  'finalgrade'=>0,  'rawgrade'=>0);
760
761        // Not enabled (should do nothing).
762        $c->expects($this->at(0))
763            ->method('is_enabled')
764            ->with($cm)
765            ->will($this->returnValue(false));
766        $c->inform_grade_changed($cm, $item, $grade, false);
767
768        // Enabled but still no grade completion required,  should still do nothing.
769        $c->expects($this->at(0))
770            ->method('is_enabled')
771            ->with($cm)
772            ->will($this->returnValue(true));
773        $c->inform_grade_changed($cm, $item, $grade, false);
774
775        // Enabled and completion required but item number is wrong,  does nothing.
776        $cm = (object)array('course'=>42, 'id'=>13, 'completion'=>0, 'completiongradeitemnumber'=>7);
777        $c->expects($this->at(0))
778            ->method('is_enabled')
779            ->with($cm)
780            ->will($this->returnValue(true));
781        $c->inform_grade_changed($cm, $item, $grade, false);
782
783        // Enabled and completion required and item number right. It is supposed
784        // to call update_state with the new potential state being obtained from
785        // internal_get_grade_state.
786        $cm = (object)array('course'=>42, 'id'=>13, 'completion'=>0, 'completiongradeitemnumber'=>3);
787        $grade = (object)array('userid'=>31337,  'finalgrade'=>1,  'rawgrade'=>0);
788        $c->expects($this->at(0))
789            ->method('is_enabled')
790            ->with($cm)
791            ->will($this->returnValue(true));
792        $c->expects($this->at(1))
793            ->method('update_state')
794            ->with($cm, COMPLETION_COMPLETE_PASS, 31337)
795            ->will($this->returnValue(true));
796        $c->inform_grade_changed($cm, $item, $grade, false);
797
798        // Same as above but marked deleted. It is supposed to call update_state
799        // with new potential state being COMPLETION_INCOMPLETE.
800        $cm = (object)array('course'=>42, 'id'=>13, 'completion'=>0, 'completiongradeitemnumber'=>3);
801        $grade = (object)array('userid'=>31337,  'finalgrade'=>1,  'rawgrade'=>0);
802        $c->expects($this->at(0))
803            ->method('is_enabled')
804            ->with($cm)
805            ->will($this->returnValue(true));
806        $c->expects($this->at(1))
807            ->method('update_state')
808            ->with($cm, COMPLETION_INCOMPLETE, 31337)
809            ->will($this->returnValue(true));
810        $c->inform_grade_changed($cm, $item, $grade, true);
811    }
812
813    public function test_internal_get_grade_state() {
814        $this->mock_setup();
815
816        $item = new stdClass;
817        $grade = new stdClass;
818
819        $item->gradepass = 4;
820        $item->hidden = 0;
821        $grade->rawgrade = 4.0;
822        $grade->finalgrade = null;
823
824        // Grade has pass mark and is not hidden,  user passes.
825        $this->assertEquals(
826            COMPLETION_COMPLETE_PASS,
827            completion_info::internal_get_grade_state($item, $grade));
828
829        // Same but user fails.
830        $grade->rawgrade = 3.9;
831        $this->assertEquals(
832            COMPLETION_COMPLETE_FAIL,
833            completion_info::internal_get_grade_state($item, $grade));
834
835        // User fails on raw grade but passes on final.
836        $grade->finalgrade = 4.0;
837        $this->assertEquals(
838            COMPLETION_COMPLETE_PASS,
839            completion_info::internal_get_grade_state($item, $grade));
840
841        // Item is hidden.
842        $item->hidden = 1;
843        $this->assertEquals(
844            COMPLETION_COMPLETE,
845            completion_info::internal_get_grade_state($item, $grade));
846
847        // Item isn't hidden but has no pass mark.
848        $item->hidden = 0;
849        $item->gradepass = 0;
850        $this->assertEquals(
851            COMPLETION_COMPLETE,
852            completion_info::internal_get_grade_state($item, $grade));
853    }
854
855    public function test_get_activities() {
856        global $CFG;
857        $this->resetAfterTest();
858
859        // Enable completion before creating modules, otherwise the completion data is not written in DB.
860        $CFG->enablecompletion = true;
861
862        // Create a course with mixed auto completion data.
863        $course = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
864        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
865        $completionmanual = array('completion' => COMPLETION_TRACKING_MANUAL);
866        $completionnone = array('completion' => COMPLETION_TRACKING_NONE);
867        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), $completionauto);
868        $page = $this->getDataGenerator()->create_module('page', array('course' => $course->id), $completionauto);
869        $data = $this->getDataGenerator()->create_module('data', array('course' => $course->id), $completionmanual);
870
871        $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), $completionnone);
872        $page2 = $this->getDataGenerator()->create_module('page', array('course' => $course->id), $completionnone);
873        $data2 = $this->getDataGenerator()->create_module('data', array('course' => $course->id), $completionnone);
874
875        // Create data in another course to make sure it's not considered.
876        $course2 = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
877        $c2forum = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id), $completionauto);
878        $c2page = $this->getDataGenerator()->create_module('page', array('course' => $course2->id), $completionmanual);
879        $c2data = $this->getDataGenerator()->create_module('data', array('course' => $course2->id), $completionnone);
880
881        $c = new completion_info($course);
882        $activities = $c->get_activities();
883        $this->assertCount(3, $activities);
884        $this->assertTrue(isset($activities[$forum->cmid]));
885        $this->assertSame($forum->name, $activities[$forum->cmid]->name);
886        $this->assertTrue(isset($activities[$page->cmid]));
887        $this->assertSame($page->name, $activities[$page->cmid]->name);
888        $this->assertTrue(isset($activities[$data->cmid]));
889        $this->assertSame($data->name, $activities[$data->cmid]->name);
890
891        $this->assertFalse(isset($activities[$forum2->cmid]));
892        $this->assertFalse(isset($activities[$page2->cmid]));
893        $this->assertFalse(isset($activities[$data2->cmid]));
894    }
895
896    public function test_has_activities() {
897        global $CFG;
898        $this->resetAfterTest();
899
900        // Enable completion before creating modules, otherwise the completion data is not written in DB.
901        $CFG->enablecompletion = true;
902
903        // Create a course with mixed auto completion data.
904        $course = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
905        $course2 = $this->getDataGenerator()->create_course(array('enablecompletion' => true));
906        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
907        $completionnone = array('completion' => COMPLETION_TRACKING_NONE);
908        $c1forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), $completionauto);
909        $c2forum = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id), $completionnone);
910
911        $c1 = new completion_info($course);
912        $c2 = new completion_info($course2);
913
914        $this->assertTrue($c1->has_activities());
915        $this->assertFalse($c2->has_activities());
916    }
917
918    /**
919     * Test that data is cleaned up when we delete courses that are set as completion criteria for other courses
920     *
921     * @return void
922     */
923    public function test_course_delete_prerequisite() {
924        global $DB;
925
926        $this->setup_data();
927
928        $courseprerequisite = $this->getDataGenerator()->create_course(['enablecompletion' => true]);
929
930        $criteriadata = (object) [
931            'id' => $this->course->id,
932            'criteria_course' => [$courseprerequisite->id],
933        ];
934
935        /** @var completion_criteria_course $criteria */
936        $criteria = completion_criteria::factory(['criteriatype' => COMPLETION_CRITERIA_TYPE_COURSE]);
937        $criteria->update_config($criteriadata);
938
939        // Sanity test.
940        $this->assertTrue($DB->record_exists('course_completion_criteria', [
941            'course' => $this->course->id,
942            'criteriatype' => COMPLETION_CRITERIA_TYPE_COURSE,
943            'courseinstance' => $courseprerequisite->id,
944        ]));
945
946        // Deleting the prerequisite course should remove the completion criteria.
947        delete_course($courseprerequisite, false);
948
949        $this->assertFalse($DB->record_exists('course_completion_criteria', [
950            'course' => $this->course->id,
951            'criteriatype' => COMPLETION_CRITERIA_TYPE_COURSE,
952            'courseinstance' => $courseprerequisite->id,
953        ]));
954    }
955
956    /**
957     * Test course module completion update event.
958     */
959    public function test_course_module_completion_updated_event() {
960        global $USER, $CFG;
961
962        $this->setup_data();
963
964        $this->setAdminUser();
965
966        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
967        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $this->course->id), $completionauto);
968
969        $c = new completion_info($this->course);
970        $activities = $c->get_activities();
971        $this->assertEquals(1, count($activities));
972        $this->assertTrue(isset($activities[$forum->cmid]));
973        $this->assertEquals($activities[$forum->cmid]->name, $forum->name);
974
975        $current = $c->get_data($activities[$forum->cmid], false, $this->user->id);
976        $current->completionstate = COMPLETION_COMPLETE;
977        $current->timemodified = time();
978        $sink = $this->redirectEvents();
979        $c->internal_set_data($activities[$forum->cmid], $current);
980        $events = $sink->get_events();
981        $event = reset($events);
982        $this->assertInstanceOf('\core\event\course_module_completion_updated', $event);
983        $this->assertEquals($forum->cmid, $event->get_record_snapshot('course_modules_completion', $event->objectid)->coursemoduleid);
984        $this->assertEquals($current, $event->get_record_snapshot('course_modules_completion', $event->objectid));
985        $this->assertEquals(context_module::instance($forum->cmid), $event->get_context());
986        $this->assertEquals($USER->id, $event->userid);
987        $this->assertEquals($this->user->id, $event->relateduserid);
988        $this->assertInstanceOf('moodle_url', $event->get_url());
989        $this->assertEventLegacyData($current, $event);
990    }
991
992    /**
993     * Test course completed event.
994     */
995    public function test_course_completed_event() {
996        global $USER;
997
998        $this->setup_data();
999        $this->setAdminUser();
1000
1001        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
1002        $ccompletion = new completion_completion(array('course' => $this->course->id, 'userid' => $this->user->id));
1003
1004        // Mark course as complete and get triggered event.
1005        $sink = $this->redirectEvents();
1006        $ccompletion->mark_complete();
1007        $events = $sink->get_events();
1008        $event = reset($events);
1009
1010        $this->assertInstanceOf('\core\event\course_completed', $event);
1011        $this->assertEquals($this->course->id, $event->get_record_snapshot('course_completions', $event->objectid)->course);
1012        $this->assertEquals($this->course->id, $event->courseid);
1013        $this->assertEquals($USER->id, $event->userid);
1014        $this->assertEquals($this->user->id, $event->relateduserid);
1015        $this->assertEquals(context_course::instance($this->course->id), $event->get_context());
1016        $this->assertInstanceOf('moodle_url', $event->get_url());
1017        $data = $ccompletion->get_record_data();
1018        $this->assertEventLegacyData($data, $event);
1019    }
1020
1021    /**
1022     * Test course completed message.
1023     */
1024    public function test_course_completed_message() {
1025        $this->setup_data();
1026        $this->setAdminUser();
1027
1028        $completionauto = array('completion' => COMPLETION_TRACKING_AUTOMATIC);
1029        $ccompletion = new completion_completion(array('course' => $this->course->id, 'userid' => $this->user->id));
1030
1031        // Mark course as complete and get the message.
1032        $sink = $this->redirectMessages();
1033        $ccompletion->mark_complete();
1034        $messages = $sink->get_messages();
1035        $sink->close();
1036
1037        $this->assertCount(1, $messages);
1038        $message = array_pop($messages);
1039
1040        $this->assertEquals(core_user::get_noreply_user()->id, $message->useridfrom);
1041        $this->assertEquals($this->user->id, $message->useridto);
1042        $this->assertEquals('coursecompleted', $message->eventtype);
1043        $this->assertEquals(get_string('coursecompleted', 'completion'), $message->subject);
1044        $this->assertStringContainsString($this->course->fullname, $message->fullmessage);
1045    }
1046
1047    /**
1048     * Test course completed event.
1049     */
1050    public function test_course_completion_updated_event() {
1051        $this->setup_data();
1052        $coursecontext = context_course::instance($this->course->id);
1053        $coursecompletionevent = \core\event\course_completion_updated::create(
1054                array(
1055                    'courseid' => $this->course->id,
1056                    'context' => $coursecontext
1057                    )
1058                );
1059
1060        // Mark course as complete and get triggered event.
1061        $sink = $this->redirectEvents();
1062        $coursecompletionevent->trigger();
1063        $events = $sink->get_events();
1064        $event = array_pop($events);
1065        $sink->close();
1066
1067        $this->assertInstanceOf('\core\event\course_completion_updated', $event);
1068        $this->assertEquals($this->course->id, $event->courseid);
1069        $this->assertEquals($coursecontext, $event->get_context());
1070        $this->assertInstanceOf('moodle_url', $event->get_url());
1071        $expectedlegacylog = array($this->course->id, 'course', 'completion updated', 'completion.php?id='.$this->course->id);
1072        $this->assertEventLegacyLogData($expectedlegacylog, $event);
1073    }
1074
1075    public function test_completion_can_view_data() {
1076        $this->setup_data();
1077
1078        $student = $this->getDataGenerator()->create_user();
1079        $this->getDataGenerator()->enrol_user($student->id, $this->course->id);
1080
1081        $this->setUser($student);
1082        $this->assertTrue(completion_can_view_data($student->id, $this->course->id));
1083        $this->assertFalse(completion_can_view_data($this->user->id, $this->course->id));
1084    }
1085}
1086
1087class core_completionlib_fake_recordset implements Iterator {
1088    protected $closed;
1089    protected $values, $index;
1090
1091    public function __construct($values) {
1092        $this->values = $values;
1093        $this->index = 0;
1094    }
1095
1096    public function current() {
1097        return $this->values[$this->index];
1098    }
1099
1100    public function key() {
1101        return $this->values[$this->index];
1102    }
1103
1104    public function next() {
1105        $this->index++;
1106    }
1107
1108    public function rewind() {
1109        $this->index = 0;
1110    }
1111
1112    public function valid() {
1113        return count($this->values) > $this->index;
1114    }
1115
1116    public function close() {
1117        $this->closed = true;
1118    }
1119
1120    public function was_closed() {
1121        return $this->closed;
1122    }
1123}
1124