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 * Testing the service layer within core_favourites.
19 *
20 * @package    core_favourites
21 * @category   test
22 * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
23 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25use \core_favourites\local\entity\favourite;
26defined('MOODLE_INTERNAL') || die();
27
28/**
29 * Test class covering the user_favourite_service within the service layer of favourites.
30 *
31 * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
32 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33 */
34class user_favourite_service_testcase extends advanced_testcase {
35
36    public function setUp(): void {
37        $this->resetAfterTest();
38    }
39
40    // Basic setup stuff to be reused in most tests.
41    protected function setup_users_and_courses() {
42        $user1 = self::getDataGenerator()->create_user();
43        $user1context = \context_user::instance($user1->id);
44        $user2 = self::getDataGenerator()->create_user();
45        $user2context = \context_user::instance($user2->id);
46        $course1 = self::getDataGenerator()->create_course();
47        $course2 = self::getDataGenerator()->create_course();
48        $course1context = context_course::instance($course1->id);
49        $course2context = context_course::instance($course2->id);
50        return [$user1context, $user2context, $course1context, $course2context];
51    }
52
53    /**
54     * Generates an in-memory repository for testing, using an array store for CRUD stuff.
55     *
56     * @param array $mockstore
57     * @return \PHPUnit\Framework\MockObject\MockObject
58     */
59    protected function get_mock_repository(array $mockstore) {
60        // This mock will just store data in an array.
61        $mockrepo = $this->getMockBuilder(\core_favourites\local\repository\favourite_repository_interface::class)
62            ->onlyMethods([])
63            ->getMock();
64        $mockrepo->expects($this->any())
65            ->method('add')
66            ->will($this->returnCallback(function(favourite $favourite) use (&$mockstore) {
67                // Mock implementation of repository->add(), where an array is used instead of the DB.
68                // Duplicates are confirmed via the unique key, and exceptions thrown just like a real repo.
69                $key = $favourite->userid . $favourite->component . $favourite->itemtype . $favourite->itemid
70                    . $favourite->contextid;
71
72                // Check the objects for the unique key.
73                foreach ($mockstore as $item) {
74                    if ($item->uniquekey == $key) {
75                        throw new \moodle_exception('Favourite already exists');
76                    }
77                }
78                $index = count($mockstore);     // Integer index.
79                $favourite->uniquekey = $key;   // Simulate the unique key constraint.
80                $favourite->id = $index;
81                $mockstore[$index] = $favourite;
82                return $mockstore[$index];
83            })
84        );
85        $mockrepo->expects($this->any())
86            ->method('find_by')
87            ->will($this->returnCallback(function(array $criteria, int $limitfrom = 0, int $limitnum = 0) use (&$mockstore) {
88                // Check for single value key pair vs multiple.
89                $multipleconditions = [];
90                foreach ($criteria as $key => $value) {
91                    if (is_array($value)) {
92                        $multipleconditions[$key] = $value;
93                        unset($criteria[$key]);
94                    }
95                }
96
97                // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
98                foreach ($mockstore as $index => $mockrow) {
99                    $mockrowarr = (array)$mockrow;
100                    if (array_diff_assoc($criteria, $mockrowarr) == []) {
101                        $found = true;
102                        foreach ($multipleconditions as $key => $value) {
103                            if (!in_array($mockrowarr[$key], $value)) {
104                                $found = false;
105                                break;
106                            }
107                        }
108                        if ($found) {
109                            $returns[$index] = $mockrow;
110                        }
111                    }
112                }
113                // Return a subset of the records, according to the paging options, if set.
114                if ($limitnum != 0) {
115                    return array_slice($returns, $limitfrom, $limitnum);
116                }
117                // Otherwise, just return the full set.
118                return $returns;
119            })
120        );
121        $mockrepo->expects($this->any())
122            ->method('find_favourite')
123            ->will($this->returnCallback(function(int $userid, string $comp, string $type, int $id, int $ctxid) use (&$mockstore) {
124                // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
125                $crit = ['userid' => $userid, 'component' => $comp, 'itemtype' => $type, 'itemid' => $id, 'contextid' => $ctxid];
126                foreach ($mockstore as $fakerow) {
127                    $fakerowarr = (array)$fakerow;
128                    if (array_diff_assoc($crit, $fakerowarr) == []) {
129                        return $fakerow;
130                    }
131                }
132                throw new \dml_missing_record_exception("Item not found");
133            })
134        );
135        $mockrepo->expects($this->any())
136            ->method('find')
137            ->will($this->returnCallback(function(int $id) use (&$mockstore) {
138                return $mockstore[$id];
139            })
140        );
141        $mockrepo->expects($this->any())
142            ->method('exists')
143            ->will($this->returnCallback(function(int $id) use (&$mockstore) {
144                return array_key_exists($id, $mockstore);
145            })
146        );
147        $mockrepo->expects($this->any())
148            ->method('count_by')
149            ->will($this->returnCallback(function(array $criteria) use (&$mockstore) {
150                $count = 0;
151                // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
152                foreach ($mockstore as $index => $mockrow) {
153                    $mockrowarr = (array)$mockrow;
154                    if (array_diff_assoc($criteria, $mockrowarr) == []) {
155                        $count++;
156                    }
157                }
158                return $count;
159            })
160        );
161        $mockrepo->expects($this->any())
162            ->method('delete')
163            ->will($this->returnCallback(function(int $id) use (&$mockstore) {
164                foreach ($mockstore as $mockrow) {
165                    if ($mockrow->id == $id) {
166                        unset($mockstore[$id]);
167                    }
168                }
169            })
170        );
171        $mockrepo->expects($this->any())
172            ->method('exists_by')
173            ->will($this->returnCallback(function(array $criteria) use (&$mockstore) {
174                // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
175                foreach ($mockstore as $index => $mockrow) {
176                    $mockrowarr = (array)$mockrow;
177                    if (array_diff_assoc($criteria, $mockrowarr) == []) {
178                        return true;
179                    }
180                }
181                return false;
182            })
183        );
184        return $mockrepo;
185    }
186
187    /**
188     * Test getting a user_favourite_service from the static locator.
189     */
190    public function test_get_service_for_user_context() {
191        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
192        $userservice = \core_favourites\service_factory::get_service_for_user_context($user1context);
193        $this->assertInstanceOf(\core_favourites\local\service\user_favourite_service::class, $userservice);
194    }
195
196    /**
197     * Test confirming an item can be favourited only once.
198     */
199    public function test_create_favourite_basic() {
200        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
201
202        // Get a user_favourite_service for a user.
203        $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB.
204        $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
205
206        // Favourite a course.
207        $favourite1 = $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
208        $this->assertObjectHasAttribute('id', $favourite1);
209
210        // Try to favourite the same course again.
211        $this->expectException('moodle_exception');
212        $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
213    }
214
215    /**
216     * Test confirming that an exception is thrown if trying to favourite an item for a non-existent component.
217     */
218    public function test_create_favourite_nonexistent_component() {
219        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
220
221        // Get a user_favourite_service for the user.
222        $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB.
223        $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
224
225        // Try to favourite something in a non-existent component.
226        $this->expectException('moodle_exception');
227        $user1service->create_favourite('core_cccourse', 'my_area', $course1context->instanceid, $course1context);
228    }
229
230    /**
231     * Test fetching favourites for single user, by area.
232     */
233    public function test_find_favourites_by_type_single_user() {
234        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
235
236        // Get a user_favourite_service for the user.
237        $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB.
238        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
239
240        // Favourite 2 courses, in separate areas.
241        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
242        $fav2 = $service->create_favourite('core_course', 'anothertype', $course2context->instanceid, $course2context);
243
244        // Verify we can get favourites by area.
245        $favourites = $service->find_favourites_by_type('core_course', 'course');
246        $this->assertIsArray($favourites);
247        $this->assertCount(1, $favourites); // We only get favourites for the 'core_course/course' area.
248        $this->assertEquals($fav1->id, $favourites[$fav1->id]->id);
249
250        $favourites = $service->find_favourites_by_type('core_course', 'anothertype');
251        $this->assertIsArray($favourites);
252        $this->assertCount(1, $favourites); // We only get favourites for the 'core_course/course' area.
253        $this->assertEquals($fav2->id, $favourites[$fav2->id]->id);
254    }
255
256    /**
257     * Test fetching favourites for single user, by area.
258     */
259    public function test_find_all_favourites() {
260        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
261
262        // Get a user_favourite_service for the user.
263        $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB.
264        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
265
266        // Favourite 2 courses, in separate areas.
267        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
268        $fav2 = $service->create_favourite('core_course', 'anothertype', $course2context->instanceid, $course2context);
269        $fav3 = $service->create_favourite('core_course', 'yetanothertype', $course2context->instanceid, $course2context);
270
271        // Verify we can get favourites by area.
272        $favourites = $service->find_all_favourites('core_course', ['course']);
273        $this->assertIsArray($favourites);
274        $this->assertCount(1, $favourites); // We only get favourites for the 'core_course/course' area.
275        $this->assertEquals($fav1->id, $favourites[$fav1->id]->id);
276
277        $favourites = $service->find_all_favourites('core_course', ['course', 'anothertype']);
278        $this->assertIsArray($favourites);
279        // We only get favourites for the 'core_course/course' and 'core_course/anothertype area.
280        $this->assertCount(2, $favourites);
281        $this->assertEquals($fav1->id, $favourites[$fav1->id]->id);
282        $this->assertEquals($fav2->id, $favourites[$fav2->id]->id);
283
284        $favourites = $service->find_all_favourites('core_course');
285        $this->assertIsArray($favourites);
286        $this->assertCount(3, $favourites); // We only get favourites for the 'core_cours' area.
287        $this->assertEquals($fav2->id, $favourites[$fav2->id]->id);
288        $this->assertEquals($fav1->id, $favourites[$fav1->id]->id);
289        $this->assertEquals($fav3->id, $favourites[$fav3->id]->id);
290    }
291
292    /**
293     * Make sure the find_favourites_by_type() method only returns favourites for the scoped user.
294     */
295    public function test_find_favourites_by_type_multiple_users() {
296        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
297
298        // Get a user_favourite_service for 2 users.
299        $repo = $this->get_mock_repository([]);
300        $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
301        $user2service = new \core_favourites\local\service\user_favourite_service($user2context, $repo);
302
303        // Now, as each user, favourite the same course.
304        $fav1 = $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
305        $fav2 = $user2service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
306
307        // Verify find_favourites_by_type only returns results for the user to which the service is scoped.
308        $user1favourites = $user1service->find_favourites_by_type('core_course', 'course');
309        $this->assertIsArray($user1favourites);
310        $this->assertCount(1, $user1favourites); // We only get favourites for the 'core_course/course' area for $user1.
311        $this->assertEquals($fav1->id, $user1favourites[$fav1->id]->id);
312
313        $user2favourites = $user2service->find_favourites_by_type('core_course', 'course');
314        $this->assertIsArray($user2favourites);
315        $this->assertCount(1, $user2favourites); // We only get favourites for the 'core_course/course' area for $user2.
316        $this->assertEquals($fav2->id, $user2favourites[$fav2->id]->id);
317    }
318
319    /**
320     * Test confirming that an exception is thrown if trying to get favourites for a non-existent component.
321     */
322    public function test_find_favourites_by_type_nonexistent_component() {
323        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
324
325        // Get a user_favourite_service for the user.
326        $repo = $this->get_mock_repository([]);
327        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
328
329        // Verify we get an exception if we try to search for favourites in an invalid component.
330        $this->expectException('moodle_exception');
331        $service->find_favourites_by_type('cccore_notreal', 'something');
332    }
333
334    /**
335     * Test confirming the pagination support for the find_favourites_by_type() method.
336     */
337    public function test_find_favourites_by_type_pagination() {
338        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
339
340        // Get a user_favourite_service for the user.
341        $repo = $this->get_mock_repository([]);
342        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
343
344        // Favourite 10 arbitrary items.
345        foreach (range(1, 10) as $i) {
346            $service->create_favourite('core_course', 'course', $i, $course1context);
347        }
348
349        // Verify we have 10 favourites.
350        $this->assertCount(10, $service->find_favourites_by_type('core_course', 'course'));
351
352        // Verify we get back 5 favourites for page 1.
353        $favourites = $service->find_favourites_by_type('core_course', 'course', 0, 5);
354        $this->assertCount(5, $favourites);
355
356        // Verify we get back 5 favourites for page 2.
357        $favourites = $service->find_favourites_by_type('core_course', 'course', 5, 5);
358        $this->assertCount(5, $favourites);
359
360        // Verify we get back an empty array if querying page 3.
361        $favourites = $service->find_favourites_by_type('core_course', 'course', 10, 5);
362        $this->assertCount(0, $favourites);
363    }
364
365    /**
366     * Test confirming the basic deletion behaviour.
367     */
368    public function test_delete_favourite_basic() {
369        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
370
371        // Get a user_favourite_service for the user.
372        $repo = $this->get_mock_repository([]);
373        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
374
375        // Favourite a course.
376        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
377        $this->assertTrue($repo->exists($fav1->id));
378
379        // Delete the favourite.
380        $service->delete_favourite('core_course', 'course', $course1context->instanceid, $course1context);
381
382        // Verify the favourite doesn't exist.
383        $this->assertFalse($repo->exists($fav1->id));
384
385        // Try to delete a favourite which we know doesn't exist.
386        $this->expectException(\moodle_exception::class);
387        $service->delete_favourite('core_course', 'course', $course1context->instanceid, $course1context);
388    }
389
390    /**
391     * Test confirming the behaviour of the favourite_exists() method.
392     */
393    public function test_favourite_exists() {
394        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
395
396        // Get a user_favourite_service for the user.
397        $repo = $this->get_mock_repository([]);
398        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
399
400        // Favourite a course.
401        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
402
403        // Verify we can check existence of the favourite.
404        $this->assertTrue(
405            $service->favourite_exists(
406                'core_course',
407                'course',
408                $course1context->instanceid,
409                $course1context
410            )
411        );
412
413        // And one that we know doesn't exist.
414        $this->assertFalse(
415            $service->favourite_exists(
416                'core_course',
417                'someothertype',
418                $course1context->instanceid,
419                $course1context
420            )
421        );
422    }
423
424    /**
425     * Test confirming the behaviour of the get_favourite() method.
426     */
427    public function test_get_favourite() {
428        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
429
430        // Get a user_favourite_service for the user.
431        $repo = $this->get_mock_repository([]);
432        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
433
434        // Favourite a course.
435        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
436
437        $result = $service->get_favourite(
438            'core_course',
439            'course',
440            $course1context->instanceid,
441            $course1context
442        );
443        // Verify we can get the favourite.
444        $this->assertEquals($fav1->id, $result->id);
445
446        // And one that we know doesn't exist.
447        $this->assertNull(
448            $service->get_favourite(
449                'core_course',
450                'someothertype',
451                $course1context->instanceid,
452                $course1context
453            )
454        );
455    }
456
457    /**
458     * Test confirming the behaviour of the count_favourites_by_type() method.
459     */
460    public function test_count_favourites_by_type() {
461        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
462
463        // Get a user_favourite_service for the user.
464        $repo = $this->get_mock_repository([]);
465        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
466
467        $this->assertEquals(0, $service->count_favourites_by_type('core_course', 'course', $course1context));
468        // Favourite a course.
469        $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
470
471        $this->assertEquals(1, $service->count_favourites_by_type('core_course', 'course', $course1context));
472
473        // Favourite another course.
474        $service->create_favourite('core_course', 'course', $course2context->instanceid, $course1context);
475
476        $this->assertEquals(2, $service->count_favourites_by_type('core_course', 'course', $course1context));
477
478        // Favourite a course in another context.
479        $service->create_favourite('core_course', 'course', $course2context->instanceid, $course2context);
480
481        // Doesn't affect original context.
482        $this->assertEquals(2, $service->count_favourites_by_type('core_course', 'course', $course1context));
483        // Gets counted if we include all contexts.
484        $this->assertEquals(3, $service->count_favourites_by_type('core_course', 'course'));
485    }
486
487    /**
488     * Verify that the join sql generated by get_join_sql_by_type is valid and can be used to include favourite information.
489     */
490    public function test_get_join_sql_by_type() {
491        global $DB;
492        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
493
494        // Get a user_favourite_service for the user.
495        // We need to use a real (DB) repository, as we want to run the SQL.
496        $repo = new \core_favourites\local\repository\favourite_repository();
497        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
498
499        // Favourite the first course only.
500        $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
501
502        // Generate the join snippet.
503        list($favsql, $favparams) = $service->get_join_sql_by_type('core_course', 'course', 'favalias', 'c.id');
504
505        // Join against a simple select, including the 2 courses only.
506        $params = ['courseid1' => $course1context->instanceid, 'courseid2' => $course2context->instanceid];
507        $params = $params + $favparams;
508        $records = $DB->get_records_sql("SELECT c.id, favalias.component
509                                           FROM {course} c $favsql
510                                          WHERE c.id = :courseid1 OR c.id = :courseid2", $params);
511
512        // Verify the favourite information is returned, but only for the favourited course.
513        $this->assertCount(2, $records);
514        $this->assertEquals('core_course', $records[$course1context->instanceid]->component);
515        $this->assertEmpty($records[$course2context->instanceid]->component);
516    }
517}
518