Completed
Push — master ( 5fedd9...740e1a )
by Robbie
42s queued 21s
created

src/Dev/SapphireTest.php (1 issue)

Labels
Severity
1
<?php
2
3
namespace SilverStripe\Dev;
4
5
use ArrayAccess;
6
use Exception;
7
use LogicException;
8
use PHPUnit\Framework\Constraint\LogicalNot;
9
use PHPUnit\Framework\ExpectationFailedException;
10
use PHPUnit\Framework\InvalidArgumentException;
11
use PHPUnit\Framework\TestCase;
12
use PHPUnit\Util\InvalidArgumentHelper;
13
use SilverStripe\CMS\Controllers\RootURLController;
14
use SilverStripe\Control\CLIRequestBuilder;
15
use SilverStripe\Control\Controller;
16
use SilverStripe\Control\Cookie;
17
use SilverStripe\Control\Director;
18
use SilverStripe\Control\Email\Email;
19
use SilverStripe\Control\Email\Mailer;
20
use SilverStripe\Control\HTTPApplication;
21
use SilverStripe\Control\HTTPRequest;
22
use SilverStripe\Core\Config\Config;
23
use SilverStripe\Core\Injector\Injector;
24
use SilverStripe\Core\Injector\InjectorLoader;
25
use SilverStripe\Core\Manifest\ClassLoader;
26
use SilverStripe\Core\Manifest\ModuleResourceLoader;
27
use SilverStripe\Dev\Constraint\ArraySubset;
28
use SilverStripe\Dev\Constraint\SSListContains;
29
use SilverStripe\Dev\Constraint\SSListContainsOnly;
30
use SilverStripe\Dev\Constraint\SSListContainsOnlyMatchingItems;
31
use SilverStripe\Dev\State\FixtureTestState;
32
use SilverStripe\Dev\State\SapphireTestState;
33
use SilverStripe\i18n\i18n;
34
use SilverStripe\ORM\Connect\TempDatabase;
35
use SilverStripe\ORM\DataObject;
36
use SilverStripe\ORM\FieldType\DBDatetime;
37
use SilverStripe\ORM\FieldType\DBField;
38
use SilverStripe\ORM\SS_List;
39
use SilverStripe\Security\Group;
40
use SilverStripe\Security\IdentityStore;
41
use SilverStripe\Security\Member;
42
use SilverStripe\Security\Permission;
43
use SilverStripe\Security\Security;
44
use SilverStripe\View\SSViewer;
45
46
if (!class_exists(TestCase::class)) {
47
    return;
48
}
49
50
/**
51
 * Test case class for the Sapphire framework.
52
 * Sapphire unit testing is based on PHPUnit, but provides a number of hooks into our data model that make it easier
53
 * to work with.
54
 *
55
 * This class should not be used anywhere outside of unit tests, as phpunit may not be installed
56
 * in production sites.
57
 */
58
abstract class SapphireTest extends TestCase implements TestOnly
59
{
60
    /**
61
     * Path to fixture data for this test run.
62
     * If passed as an array, multiple fixture files will be loaded.
63
     * Please note that you won't be able to refer with "=>" notation
64
     * between the fixtures, they act independent of each other.
65
     *
66
     * @var string|array
67
     */
68
    protected static $fixture_file = '';
69
70
    /**
71
     * @deprecated 4.0..5.0 Use FixtureTestState instead
72
     * @var FixtureFactory
73
     */
74
    protected $fixtureFactory;
75
76
    /**
77
     * @var Boolean If set to TRUE, this will force a test database to be generated
78
     * in {@link setUp()}. Note that this flag is overruled by the presence of a
79
     * {@link $fixture_file}, which always forces a database build.
80
     *
81
     * @var bool
82
     */
83
    protected $usesDatabase = null;
84
85
    /**
86
     * This test will cleanup its state via transactions.
87
     * If set to false a full schema is forced between tests, but at a performance cost.
88
     *
89
     * @var bool
90
     */
91
    protected $usesTransactions = true;
92
93
    /**
94
     * @var bool
95
     */
96
    protected static $is_running_test = false;
97
98
    /**
99
     * By default, setUp() does not require default records. Pass
100
     * class names in here, and the require/augment default records
101
     * function will be called on them.
102
     *
103
     * @var array
104
     */
105
    protected $requireDefaultRecordsFrom = array();
106
107
    /**
108
     * A list of extensions that can't be applied during the execution of this run.  If they are
109
     * applied, they will be temporarily removed and a database migration called.
110
     *
111
     * The keys of the are the classes that the extensions can't be applied the extensions to, and
112
     * the values are an array of illegal extensions on that class.
113
     *
114
     * Set a class to `*` to remove all extensions (unadvised)
115
     *
116
     * @var array
117
     */
118
    protected static $illegal_extensions = [];
119
120
    /**
121
     * A list of extensions that must be applied during the execution of this run.  If they are
122
     * not applied, they will be temporarily added and a database migration called.
123
     *
124
     * The keys of the are the classes to apply the extensions to, and the values are an array
125
     * of required extensions on that class.
126
     *
127
     * Example:
128
     * <code>
129
     * array("MyTreeDataObject" => array("Versioned", "Hierarchy"))
130
     * </code>
131
     *
132
     * @var array
133
     */
134
    protected static $required_extensions = [];
135
136
    /**
137
     * By default, the test database won't contain any DataObjects that have the interface TestOnly.
138
     * This variable lets you define additional TestOnly DataObjects to set up for this test.
139
     * Set it to an array of DataObject subclass names.
140
     *
141
     * @var array
142
     */
143
    protected static $extra_dataobjects = [];
144
145
    /**
146
     * List of class names of {@see Controller} objects to register routes for
147
     * Controllers must implement Link() method
148
     *
149
     * @var array
150
     */
151
    protected static $extra_controllers = [];
152
153
    /**
154
     * We need to disabling backing up of globals to avoid overriding
155
     * the few globals SilverStripe relies on, like $lang for the i18n subsystem.
156
     *
157
     * @see http://sebastian-bergmann.de/archives/797-Global-Variables-and-PHPUnit.html
158
     */
159
    protected $backupGlobals = false;
160
161
    /**
162
     * State management container for SapphireTest
163
     *
164
     * @var SapphireTestState
165
     */
166
    protected static $state = null;
167
168
    /**
169
     * Temp database helper
170
     *
171
     * @var TempDatabase
172
     */
173
    protected static $tempDB = null;
174
175
    /**
176
     * @return TempDatabase
177
     */
178
    public static function tempDB()
179
    {
180
        if (!static::$tempDB) {
181
            static::$tempDB = TempDatabase::create();
182
        }
183
        return static::$tempDB;
184
    }
185
186
    /**
187
     * Gets illegal extensions for this class
188
     *
189
     * @return array
190
     */
191
    public static function getIllegalExtensions() : array
192
    {
193
        return static::$illegal_extensions;
194
    }
195
196
    /**
197
     * Gets required extensions for this class
198
     *
199
     * @return array
200
     */
201
    public static function getRequiredExtensions() : array
202
    {
203
        return static::$required_extensions;
204
    }
205
206
    /**
207
     * Check if test bootstrapping has been performed. Must not be relied on
208
     * outside of unit tests.
209
     *
210
     * @return bool
211
     */
212
    protected static function is_running_test() : bool
213
    {
214
        return self::$is_running_test;
215
    }
216
217
    /**
218
     * Set test running state
219
     *
220
     * @param bool $bool
221
     */
222
    protected static function set_is_running_test(bool $bool) : void
223
    {
224
        self::$is_running_test = $bool;
225
    }
226
227
    /**
228
     * @return string|string[]
229
     */
230
    public static function get_fixture_file()
231
    {
232
        return static::$fixture_file;
233
    }
234
235
    /**
236
     * @return bool
237
     */
238
    public function getUsesDatabase()
239
    {
240
        return $this->usesDatabase;
241
    }
242
243
    /**
244
     * @return bool
245
     */
246
    public function getUsesTransactions()
247
    {
248
        return $this->usesTransactions;
249
    }
250
251
    /**
252
     * @return array
253
     */
254
    public function getRequireDefaultRecordsFrom()
255
    {
256
        return $this->requireDefaultRecordsFrom;
257
    }
258
259
    /**
260
     * Setup  the test.
261
     * Always sets up in order:
262
     *  - Reset php state
263
     *  - Nest
264
     *  - Custom state helpers
265
     *
266
     * User code should call parent::setUp() before custom setup code
267
     */
268
    protected function setUp() : void
269
    {
270
        if (!defined('FRAMEWORK_PATH')) {
271
            trigger_error(
272
                'Missing constants, did you remember to include the test bootstrap in your phpunit.xml file?',
273
                E_USER_WARNING
274
            );
275
        }
276
277
        // Call state helpers
278
        static::$state->setUp($this);
279
280
        // i18n needs to be set to the defaults or tests fail
281
        i18n::set_locale(i18n::config()->uninherited('default_locale'));
282
283
        // Set default timezone consistently to avoid NZ-specific dependencies
284
        date_default_timezone_set('UTC');
285
286
        Member::set_password_validator(null);
287
        Cookie::config()->update('report_errors', false);
288
        if (class_exists(RootURLController::class)) {
289
            RootURLController::reset();
290
        }
291
292
        Security::clear_database_is_ready();
293
294
        // Set up test routes
295
        $this->setUpRoutes();
296
297
        $fixtureFiles = $this->getFixturePaths();
298
299
        if ($this->shouldSetupDatabaseForCurrentTest($fixtureFiles)) {
300
            // Assign fixture factory to deprecated prop in case old tests use it over the getter
301
            /** @var FixtureTestState $fixtureState */
302
            $fixtureState = static::$state->getStateByName('fixtures');
303
            $this->fixtureFactory = $fixtureState->getFixtureFactory(static::class);
304
305
            $this->logInWithPermission('ADMIN');
306
        }
307
308
        // turn off template debugging
309
        SSViewer::config()->update('source_file_comments', false);
310
311
        // Set up the test mailer
312
        Injector::inst()->registerService(new TestMailer(), Mailer::class);
313
        Email::config()->remove('send_all_emails_to');
314
        Email::config()->remove('send_all_emails_from');
315
        Email::config()->remove('cc_all_emails_to');
316
        Email::config()->remove('bcc_all_emails_to');
317
    }
318
319
320
321
    /**
322
     * Helper method to determine if the current test should enable a test database
323
     *
324
     * @param array|string $fixtureFiles
325
     * @return bool
326
     */
327
    protected function shouldSetupDatabaseForCurrentTest($fixtureFiles) : bool
328
    {
329
        $databaseEnabledByDefault = $fixtureFiles || $this->usesDatabase;
330
331
        return ($databaseEnabledByDefault && !$this->currentTestDisablesDatabase())
332
            || $this->currentTestEnablesDatabase();
333
    }
334
335
    /**
336
     * Helper method to check, if the current test uses the database.
337
     * This can be switched on with the annotation "@useDatabase"
338
     *
339
     * @return bool
340
     */
341
    protected function currentTestEnablesDatabase() : bool
342
    {
343
        $annotations = $this->getAnnotations();
344
345
        return array_key_exists('useDatabase', $annotations['method'])
346
            && $annotations['method']['useDatabase'][0] !== 'false';
347
    }
348
349
    /**
350
     * Helper method to check, if the current test uses the database.
351
     * This can be switched on with the annotation "@useDatabase false"
352
     *
353
     * @return bool
354
     */
355
    protected function currentTestDisablesDatabase() : bool
356
    {
357
        $annotations = $this->getAnnotations();
358
359
        return array_key_exists('useDatabase', $annotations['method'])
360
            && $annotations['method']['useDatabase'][0] === 'false';
361
    }
362
363
    /**
364
     * Called once per test case ({@link SapphireTest} subclass).
365
     * This is different to {@link setUp()}, which gets called once
366
     * per method. Useful to initialize expensive operations which
367
     * don't change state for any called method inside the test,
368
     * e.g. dynamically adding an extension. See {@link teardownAfterClass()}
369
     * for tearing down the state again.
370
     *
371
     * Always sets up in order:
372
     *  - Reset php state
373
     *  - Nest
374
     *  - Custom state helpers
375
     *
376
     * User code should call parent::setUpBeforeClass() before custom setup code
377
     *
378
     * @throws Exception
379
     */
380
    public static function setUpBeforeClass() : void
381
    {
382
        // Start tests
383
        static::start();
384
385
        if (!static::$state) {
386
            throw new Exception('SapphireTest failed to bootstrap!');
387
        }
388
389
        // Call state helpers
390
        static::$state->setUpOnce(static::class);
391
392
        // Build DB if we have objects
393
        if (static::getExtraDataObjects()) {
394
            DataObject::reset();
395
            static::resetDBSchema(true, true);
396
        }
397
    }
398
399
    /**
400
     * tearDown method that's called once per test class rather once per test method.
401
     *
402
     * Always sets up in order:
403
     *  - Custom state helpers
404
     *  - Unnest
405
     *  - Reset php state
406
     *
407
     * User code should call parent::tearDownAfterClass() after custom tear down code
408
     */
409
    public static function tearDownAfterClass() : void
410
    {
411
        // Call state helpers
412
        static::$state->tearDownOnce(static::class);
413
414
        // Reset DB schema
415
        static::resetDBSchema();
416
    }
417
418
    /**
419
     * @deprecated 4.0.0:5.0.0
420
     * @return FixtureFactory|false
421
     */
422
    public function getFixtureFactory() : FixtureFactory
423
    {
424
        Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
425
        /** @var FixtureTestState $state */
426
        $state = static::$state->getStateByName('fixtures');
427
        return $state->getFixtureFactory(static::class);
428
    }
429
430
    /**
431
     * Sets a new fixture factory
432
     * @deprecated 4.0.0:5.0.0
433
     * @param FixtureFactory $factory
434
     * @return $this
435
     */
436
    public function setFixtureFactory(FixtureFactory $factory) : SapphireTest
437
    {
438
        Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
439
        /** @var FixtureTestState $state */
440
        $state = static::$state->getStateByName('fixtures');
441
        $state->setFixtureFactory($factory, static::class);
442
        $this->fixtureFactory = $factory;
443
        return $this;
444
    }
445
446
    /**
447
     * Get the ID of an object from the fixture.
448
     *
449
     * @param string $className The data class or table name, as specified in your fixture file.
450
     *                          Parent classes won't work
451
     * @param string $identifier The identifier string, as provided in your fixture file
452
     * @return int
453
     */
454
    protected function idFromFixture(string $className, string $identifier) : int
455
    {
456
        /** @var FixtureTestState $state */
457
        $state = static::$state->getStateByName('fixtures');
458
        $id = $state->getFixtureFactory(static::class)->getId($className, $identifier);
459
460
        if (!$id) {
461
            user_error(sprintf(
462
                "Couldn't find object '%s' (class: %s)",
463
                $identifier,
464
                $className
465
            ), E_USER_ERROR);
466
        }
467
468
        return $id;
469
    }
470
471
    /**
472
     * Return all of the IDs in the fixture of a particular class name.
473
     * Will collate all IDs form all fixtures if multiple fixtures are provided.
474
     *
475
     * @param string $className The data class or table name, as specified in your fixture file
476
     * @return array A map of fixture-identifier => object-id
477
     */
478
    protected function allFixtureIDs(string $className) : array
479
    {
480
        /** @var FixtureTestState $state */
481
        $state = static::$state->getStateByName('fixtures');
482
        return $state->getFixtureFactory(static::class)->getIds($className);
483
    }
484
485
    /**
486
     * Get an object from the fixture.
487
     *
488
     * @param string $className The data class or table name, as specified in your fixture file.
489
     *                          Parent classes won't work
490
     * @param string $identifier The identifier string, as provided in your fixture file
491
     *
492
     * @return DataObject
493
     */
494
    protected function objFromFixture(string $className, string $identifier) : DataObject
495
    {
496
        /** @var FixtureTestState $state */
497
        $state = static::$state->getStateByName('fixtures');
498
        $obj = $state->getFixtureFactory(static::class)->get($className, $identifier);
499
500
        if (!$obj) {
501
            user_error(sprintf(
502
                "Couldn't find object '%s' (class: %s)",
503
                $identifier,
504
                $className
505
            ), E_USER_ERROR);
506
        }
507
508
        return $obj;
509
    }
510
511
    /**
512
     * Load a YAML fixture file into the database.
513
     * Once loaded, you can use idFromFixture() and objFromFixture() to get items from the fixture.
514
     * Doesn't clear existing fixtures.
515
     * @deprecated 4.0.0:5.0.0
516
     *
517
     * @param string $fixtureFile The location of the .yml fixture file, relative to the site base dir
518
     */
519
    public function loadFixture(string $fixtureFile) : void
520
    {
521
        Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
522
        $fixture = Injector::inst()->create(YamlFixture::class, $fixtureFile);
523
        $fixture->writeInto($this->getFixtureFactory());
524
    }
525
526
    /**
527
     * Clear all fixtures which were previously loaded through
528
     * {@link loadFixture()}
529
     */
530
    public function clearFixtures() : void
531
    {
532
        /** @var FixtureTestState $state */
533
        $state = static::$state->getStateByName('fixtures');
534
        $state->getFixtureFactory(static::class)->clear();
535
    }
536
537
    /**
538
     * Useful for writing unit tests without hardcoding folder structures.
539
     *
540
     * @return string Absolute path to current class.
541
     */
542
    protected function getCurrentAbsolutePath() : string
543
    {
544
        $filename = ClassLoader::inst()->getItemPath(static::class);
545
        if (!$filename) {
546
            throw new LogicException('getItemPath returned null for ' . static::class
547
                . '. Try adding flush=1 to the test run.');
548
        }
549
        return dirname($filename);
550
    }
551
552
    /**
553
     * @return string File path relative to webroot
554
     */
555
    protected function getCurrentRelativePath() : string
556
    {
557
        $base = Director::baseFolder();
558
        $path = $this->getCurrentAbsolutePath();
559
        if (substr($path, 0, strlen($base)) == $base) {
560
            $path = preg_replace('/^\/*/', '', substr($path, strlen($base)));
561
        }
562
        return $path;
563
    }
564
565
    /**
566
     * Setup  the test.
567
     * Always sets up in order:
568
     *  - Custom state helpers
569
     *  - Unnest
570
     *  - Reset php state
571
     *
572
     * User code should call parent::tearDown() after custom tear down code
573
     */
574
    protected function tearDown() : void
575
    {
576
        // Reset mocked datetime
577
        DBDatetime::clear_mock_now();
578
579
        // Stop the redirection that might have been requested in the test.
580
        // Note: Ideally a clean Controller should be created for each test.
581
        // Now all tests executed in a batch share the same controller.
582
        $controller = Controller::has_curr() ? Controller::curr() : null;
583
        if ($controller && ($response = $controller->getResponse()) && $response->getHeader('Location')) {
584
            $response->setStatusCode(200);
585
            $response->removeHeader('Location');
586
        }
587
588
        // Call state helpers
589
        static::$state->tearDown($this);
590
    }
591
592
    /**
593
     * Asserts that an array has a specified subset.
594
     *
595
     * @param array|ArrayAccess $subset
596
     * @param array|ArrayAccess $array
597
     *
598
     * @throws ExpectationFailedException
599
     * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
600
     * @throws \PHPUnit\Framework\Exception
601
     *
602
     * @codeCoverageIgnore
603
     */
604
    public static function assertArraySubset(
605
        $subset,
606
        $array,
607
        bool $checkForObjectIdentity = false,
608
        string $message = ''
609
    ): void {
610
        if (!(\is_array($subset) || $subset instanceof ArrayAccess)) {
611
            throw InvalidArgumentException::create(
612
                1,
613
                'array or ArrayAccess'
614
            );
615
        }
616
617
        if (!(\is_array($array) || $array instanceof ArrayAccess)) {
618
            throw InvalidArgumentException::create(
619
                2,
620
                'array or ArrayAccess'
621
            );
622
        }
623
624
        $constraint = new ArraySubset($subset, $checkForObjectIdentity);
0 ignored issues
show
It seems like $subset can also be of type ArrayAccess; however, parameter $subset of SilverStripe\Dev\Constra...aySubset::__construct() does only seem to accept iterable, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

624
        $constraint = new ArraySubset(/** @scrutinizer ignore-type */ $subset, $checkForObjectIdentity);
Loading history...
625
626
        static::assertThat($array, $constraint, $message);
627
    }
628
629
    public static function assertContains(
630
        $needle,
631
        $haystack,
632
        string $message = '',
633
        bool $ignoreCase = false,
634
        bool $checkForObjectIdentity = true,
635
        bool $checkForNonObjectIdentity = false
636
    ) : void {
637
        if ($haystack instanceof DBField) {
638
            $haystack = (string)$haystack;
639
        }
640
        parent::assertContains(
641
            $needle,
642
            $haystack,
643
            $message,
644
            $ignoreCase,
645
            $checkForObjectIdentity,
646
            $checkForNonObjectIdentity
647
        );
648
    }
649
650
    public static function assertNotContains(
651
        $needle,
652
        $haystack,
653
        string $message = '',
654
        bool $ignoreCase = false,
655
        bool $checkForObjectIdentity = true,
656
        bool $checkForNonObjectIdentity = false
657
    ) : void {
658
        if ($haystack instanceof DBField) {
659
            $haystack = (string)$haystack;
660
        }
661
        parent::assertNotContains(
662
            $needle,
663
            $haystack,
664
            $message,
665
            $ignoreCase,
666
            $checkForObjectIdentity,
667
            $checkForNonObjectIdentity
668
        );
669
    }
670
671
    /**
672
     * Clear the log of emails sent
673
     *
674
     * @return bool True if emails cleared
675
     */
676
    public function clearEmails() : bool
677
    {
678
        /** @var Mailer $mailer */
679
        $mailer = Injector::inst()->get(Mailer::class);
680
        if ($mailer instanceof TestMailer) {
681
            $mailer->clearEmails();
682
            return true;
683
        }
684
        return false;
685
    }
686
687
    /**
688
     * Search for an email that was sent.
689
     * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
690
     * @param string $to
691
     * @param string $from
692
     * @param string $subject
693
     * @param string $content
694
     * @return array Contains keys: 'Type', 'To', 'From', 'Subject', 'Content', 'PlainContent', 'AttachedFiles',
695
     *               'HtmlContent'
696
     */
697
    public static function findEmail($to, $from = null, $subject = null, $content = null) : array
698
    {
699
        /** @var Mailer $mailer */
700
        $mailer = Injector::inst()->get(Mailer::class);
701
        if ($mailer instanceof TestMailer) {
702
            return $mailer->findEmail($to, $from, $subject, $content);
703
        }
704
        return [];
705
    }
706
707
    /**
708
     * Assert that the matching email was sent since the last call to clearEmails()
709
     * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
710
     *
711
     * @param string $to
712
     * @param string $from
713
     * @param string $subject
714
     * @param string $content
715
     */
716
    public static function assertEmailSent($to, $from = null, $subject = null, $content = null) : void
717
    {
718
        $found = (bool)static::findEmail($to, $from, $subject, $content);
719
720
        $infoParts = '';
721
        $withParts = array();
722
        if ($to) {
723
            $infoParts .= " to '$to'";
724
        }
725
        if ($from) {
726
            $infoParts .= " from '$from'";
727
        }
728
        if ($subject) {
729
            $withParts[] = "subject '$subject'";
730
        }
731
        if ($content) {
732
            $withParts[] = "content '$content'";
733
        }
734
        if ($withParts) {
735
            $infoParts .= ' with ' . implode(' and ', $withParts);
736
        }
737
738
        static::assertTrue(
739
            $found,
740
            "Failed asserting that an email was sent$infoParts."
741
        );
742
    }
743
744
745
    /**
746
     * Assert that the given {@link SS_List} includes DataObjects matching the given key-value
747
     * pairs.  Each match must correspond to 1 distinct record.
748
     *
749
     * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
750
     * either pass a single pattern or an array of patterns.
751
     * @param SS_List $list The {@link SS_List} to test.
752
     * @param string $message
753
     *
754
     * Examples
755
     * --------
756
     * Check that $members includes an entry with Email = [email protected]:
757
     *      $this->assertListContains(['Email' => '[email protected]'], $members);
758
     *
759
     * Check that $members includes entries with Email = [email protected] and with
760
     * Email = [email protected]:
761
     *      $this->assertListContains([
762
     *         ['Email' => '[email protected]'],
763
     *         ['Email' => '[email protected]'],
764
     *      ], $members);
765
     */
766
    public static function assertListContains($matches, SS_List $list, $message = '') : void
767
    {
768
        if (!is_array($matches)) {
769
            throw InvalidArgumentHelper::factory(
770
                1,
771
                'array'
772
            );
773
        }
774
775
        static::assertThat(
776
            $list,
777
            new SSListContains(
778
                $matches
779
            ),
780
            $message
781
        );
782
    }
783
784
    /**
785
     * @deprecated 4.0.0:5.0.0 Use assertListContains() instead
786
     *
787
     * @param array $matches
788
     * @param $dataObjectSet
789
     */
790
    public function assertDOSContains($matches, $dataObjectSet)
791
    {
792
        Deprecation::notice('5.0', 'Use assertListContains() instead');
793
        static::assertListContains($matches, $dataObjectSet);
794
    }
795
796
    /**
797
     * Asserts that no items in a given list appear in the given dataobject list
798
     *
799
     * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
800
     * either pass a single pattern or an array of patterns.
801
     * @param SS_List $list The {@link SS_List} to test.
802
     * @param string $message
803
     *
804
     * Examples
805
     * --------
806
     * Check that $members doesn't have an entry with Email = [email protected]:
807
     *      $this->assertListNotContains(['Email' => '[email protected]'], $members);
808
     *
809
     * Check that $members doesn't have entries with Email = [email protected] and with
810
     * Email = [email protected]:
811
     *      $this->assertListNotContains([
812
     *          ['Email' => '[email protected]'],
813
     *          ['Email' => '[email protected]'],
814
     *      ], $members);
815
     */
816
    public static function assertListNotContains($matches, SS_List $list, $message = '') : void
817
    {
818
        if (!is_array($matches)) {
819
            throw InvalidArgumentHelper::factory(
820
                1,
821
                'array'
822
            );
823
        }
824
825
        $constraint =  new LogicalNot(
826
            new SSListContains(
827
                $matches
828
            )
829
        );
830
831
        static::assertThat(
832
            $list,
833
            $constraint,
834
            $message
835
        );
836
    }
837
838
    /**
839
     * @deprecated 4.0.0:5.0.0 Use assertListNotContains() instead
840
     *
841
     * @param $matches
842
     * @param $dataObjectSet
843
     */
844
    public static function assertNotDOSContains($matches, $dataObjectSet)
845
    {
846
        Deprecation::notice('5.0', 'Use assertListNotContains() instead');
847
        static::assertListNotContains($matches, $dataObjectSet);
848
    }
849
850
    /**
851
     * Assert that the given {@link SS_List} includes only DataObjects matching the given
852
     * key-value pairs.  Each match must correspond to 1 distinct record.
853
     *
854
     * Example
855
     * --------
856
     * Check that *only* the entries Sam Minnee and Ingo Schommer exist in $members.  Order doesn't
857
     * matter:
858
     *     $this->assertListEquals([
859
     *        ['FirstName' =>'Sam', 'Surname' => 'Minnee'],
860
     *        ['FirstName' => 'Ingo', 'Surname' => 'Schommer'],
861
     *      ], $members);
862
     *
863
     * @param mixed $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
864
     * either pass a single pattern or an array of patterns.
865
     * @param mixed $list The {@link SS_List} to test.
866
     * @param string $message
867
     */
868
    public static function assertListEquals($matches, SS_List $list, $message = '') : void
869
    {
870
        if (!is_array($matches)) {
871
            throw InvalidArgumentHelper::factory(
872
                1,
873
                'array'
874
            );
875
        }
876
877
        static::assertThat(
878
            $list,
879
            new SSListContainsOnly(
880
                $matches
881
            ),
882
            $message
883
        );
884
    }
885
886
    /**
887
     * @deprecated 4.0.0:5.0.0 Use assertListEquals() instead
888
     *
889
     * @param $matches
890
     * @param SS_List $dataObjectSet
891
     */
892
    public function assertDOSEquals($matches, $dataObjectSet)
893
    {
894
        Deprecation::notice('5.0', 'Use assertListEquals() instead');
895
        static::assertListEquals($matches, $dataObjectSet);
896
    }
897
898
899
    /**
900
     * Assert that the every record in the given {@link SS_List} matches the given key-value
901
     * pairs.
902
     *
903
     * Example
904
     * --------
905
     * Check that every entry in $members has a Status of 'Active':
906
     *     $this->assertListAllMatch(['Status' => 'Active'], $members);
907
     *
908
     * @param mixed $match The pattern to match.  The pattern is a map of key-value pairs.
909
     * @param mixed $list The {@link SS_List} to test.
910
     * @param string $message
911
     */
912
    public static function assertListAllMatch($match, SS_List $list, $message = '') : void
913
    {
914
        if (!is_array($match)) {
915
            throw PHPUnit_Util_InvalidArgumentHelper::factory(
916
                1,
917
                'array'
918
            );
919
        }
920
921
        static::assertThat(
922
            $list,
923
            new SSListContainsOnlyMatchingItems(
924
                $match
925
            ),
926
            $message
927
        );
928
    }
929
930
    /**
931
     * @deprecated 4.0.0:5.0.0 Use assertListAllMatch() instead
932
     *
933
     * @param $match
934
     * @param SS_List $dataObjectSet
935
     */
936
    public function assertDOSAllMatch($match, SS_List $dataObjectSet)
937
    {
938
        Deprecation::notice('5.0', 'Use assertListAllMatch() instead');
939
        static::assertListAllMatch($match, $dataObjectSet);
940
    }
941
942
    /**
943
     * Removes sequences of repeated whitespace characters from SQL queries
944
     * making them suitable for string comparison
945
     *
946
     * @param string $sql
947
     * @return string The cleaned and normalised SQL string
948
     */
949
    protected static function normaliseSQL($sql) : string
950
    {
951
        return trim(preg_replace('/\s+/m', ' ', $sql));
952
    }
953
954
    /**
955
     * Asserts that two SQL queries are equivalent
956
     *
957
     * @param string $expectedSQL
958
     * @param string $actualSQL
959
     * @param string $message
960
     * @param float|int $delta
961
     * @param integer $maxDepth
962
     * @param boolean $canonicalize
963
     * @param boolean $ignoreCase
964
     */
965
    public static function assertSQLEquals(
966
        $expectedSQL,
967
        $actualSQL,
968
        string $message = '',
969
        int $delta = 0,
970
        int $maxDepth = 10,
971
        bool $canonicalize = false,
972
        bool $ignoreCase = false
973
    ) : void {
974
        // Normalise SQL queries to remove patterns of repeating whitespace
975
        $expectedSQL = static::normaliseSQL($expectedSQL);
976
        $actualSQL = static::normaliseSQL($actualSQL);
977
978
        static::assertEquals($expectedSQL, $actualSQL, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
979
    }
980
981
    /**
982
     * Asserts that a SQL query contains a SQL fragment
983
     *
984
     * @param string $needleSQL
985
     * @param string $haystackSQL
986
     * @param string $message
987
     * @param boolean $ignoreCase
988
     * @param boolean $checkForObjectIdentity
989
     */
990
    public static function assertSQLContains(
991
        $needleSQL,
992
        $haystackSQL,
993
        string $message = '',
994
        bool $ignoreCase = false,
995
        bool $checkForObjectIdentity = true
996
    ) : void {
997
        $needleSQL = static::normaliseSQL($needleSQL);
998
        $haystackSQL = static::normaliseSQL($haystackSQL);
999
1000
        static::assertStringContainsString($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
1001
    }
1002
1003
    /**
1004
     * Asserts that a SQL query contains a SQL fragment
1005
     *
1006
     * @param string $needleSQL
1007
     * @param string $haystackSQL
1008
     * @param string $message
1009
     * @param boolean $ignoreCase
1010
     * @param boolean $checkForObjectIdentity
1011
     */
1012
    public static function assertSQLNotContains(
1013
        $needleSQL,
1014
        $haystackSQL,
1015
        string $message = '',
1016
        bool $ignoreCase = false,
1017
        bool $checkForObjectIdentity = true
1018
    ) : void {
1019
        $needleSQL = static::normaliseSQL($needleSQL);
1020
        $haystackSQL = static::normaliseSQL($haystackSQL);
1021
1022
        static::assertStringNotContainsString($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
1023
    }
1024
1025
    /**
1026
     * Start test environment
1027
     */
1028
    public static function start() : void
1029
    {
1030
        if (static::is_running_test()) {
1031
            return;
1032
        }
1033
1034
        // Health check
1035
        if (InjectorLoader::inst()->countManifests()) {
1036
            throw new LogicException('SapphireTest::start() cannot be called within another application');
1037
        }
1038
        static::set_is_running_test(true);
1039
1040
        // Mock request
1041
        $_SERVER['argv'] = ['vendor/bin/phpunit', '/'];
1042
        $request = CLIRequestBuilder::createFromEnvironment();
1043
1044
        // Test application
1045
        $kernel = new TestKernel(BASE_PATH);
1046
        $app = new HTTPApplication($kernel);
1047
        $flush = array_key_exists('flush', $request->getVars());
1048
1049
        // Custom application
1050
        $app->execute($request, function (HTTPRequest $request) {
1051
            // Start session and execute
1052
            $request->getSession()->init($request);
1053
1054
            // Invalidate classname spec since the test manifest will now pull out new subclasses for each
1055
            // internal class (e.g. Member will now have various subclasses of DataObjects that implement TestOnly)
1056
            DataObject::reset();
1057
1058
            // Set dummy controller;
1059
            $controller = Controller::create();
1060
            $controller->setRequest($request);
1061
            $controller->pushCurrent();
1062
            $controller->doInit();
1063
        }, $flush);
1064
1065
        // Register state
1066
        static::$state = SapphireTestState::singleton();
1067
        // Register temp DB holder
1068
        static::tempDB();
1069
    }
1070
1071
    /**
1072
     * Reset the testing database's schema, but only if it is active
1073
     * @param bool $includeExtraDataObjects If true, the extraDataObjects tables will also be included
1074
     * @param bool $forceCreate Force DB to be created if it doesn't exist
1075
     */
1076
    public static function resetDBSchema(bool $includeExtraDataObjects = false, bool $forceCreate = false) : void
1077
    {
1078
        // Check if DB is active before reset
1079
        if (!static::$tempDB->isUsed()) {
1080
            if (!$forceCreate) {
1081
                return;
1082
            }
1083
            static::$tempDB->build();
1084
        }
1085
        $extraDataObjects = $includeExtraDataObjects ? static::getExtraDataObjects() : [];
1086
        static::$tempDB->resetDBSchema((array)$extraDataObjects);
1087
    }
1088
1089
    /**
1090
     * A wrapper for automatically performing callbacks as a user with a specific permission
1091
     *
1092
     * @param string|array $permCode
1093
     * @param callable $callback
1094
     * @return mixed
1095
     */
1096
    public function actWithPermission($permCode, callable $callback)
1097
    {
1098
        return Member::actAs($this->createMemberWithPermission($permCode), $callback);
1099
    }
1100
1101
    /**
1102
     * Create Member and Group objects on demand with specific permission code
1103
     *
1104
     * @param string|array $permCode
1105
     * @return Member
1106
     */
1107
    protected function createMemberWithPermission($permCode) : Member
1108
    {
1109
        if (is_array($permCode)) {
1110
            $permArray = $permCode;
1111
            $permCode = implode('.', $permCode);
1112
        } else {
1113
            $permArray = array($permCode);
1114
        }
1115
1116
        // Check cached member
1117
        if (isset($this->cache_generatedMembers[$permCode])) {
1118
            $member = $this->cache_generatedMembers[$permCode];
1119
        } else {
1120
            // Generate group with these permissions
1121
            $group = Group::create();
1122
            $group->Title = "$permCode group";
1123
            $group->write();
1124
1125
            // Create each individual permission
1126
            foreach ($permArray as $permArrayItem) {
1127
                $permission = Permission::create();
1128
                $permission->Code = $permArrayItem;
1129
                $permission->write();
1130
                $group->Permissions()->add($permission);
1131
            }
1132
1133
            $member = Member::get()->filter([
1134
                'Email' => "[email protected]",
1135
            ])->first();
1136
            if (!$member) {
1137
                $member = Member::create();
1138
            }
1139
1140
            $member->FirstName = $permCode;
1141
            $member->Surname = 'User';
1142
            $member->Email = "[email protected]";
1143
            $member->write();
1144
            $group->Members()->add($member);
1145
1146
            $this->cache_generatedMembers[$permCode] = $member;
1147
        }
1148
        return $member;
1149
    }
1150
1151
    /**
1152
     * Create a member and group with the given permission code, and log in with it.
1153
     * Returns the member ID.
1154
     *
1155
     * @param string|array $permCode Either a permission, or list of permissions
1156
     * @return int Member ID
1157
     */
1158
    public function logInWithPermission($permCode = 'ADMIN')
1159
    {
1160
        $member = $this->createMemberWithPermission($permCode);
1161
        $this->logInAs($member);
1162
        return $member->ID;
1163
    }
1164
1165
    /**
1166
     * Log in as the given member
1167
     *
1168
     * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in
1169
     */
1170
    public function logInAs($member) : void
1171
    {
1172
        if (is_numeric($member)) {
1173
            $member = DataObject::get_by_id(Member::class, $member);
1174
        } elseif (!is_object($member)) {
1175
            $member = $this->objFromFixture(Member::class, $member);
1176
        }
1177
        Injector::inst()->get(IdentityStore::class)->logIn($member);
1178
    }
1179
1180
    /**
1181
     * Log out the current user
1182
     */
1183
    public function logOut() : void
1184
    {
1185
        /** @var IdentityStore $store */
1186
        $store = Injector::inst()->get(IdentityStore::class);
1187
        $store->logOut();
1188
    }
1189
1190
    /**
1191
     * Cache for logInWithPermission()
1192
     */
1193
    protected $cache_generatedMembers = [];
1194
1195
    /**
1196
     * Test against a theme.
1197
     *
1198
     * @param string $themeBaseDir themes directory
1199
     * @param string $theme Theme name
1200
     * @param callable $callback
1201
     * @throws Exception
1202
     */
1203
    protected function useTestTheme(string $themeBaseDir, string $theme, callable $callback) : void
1204
    {
1205
        Config::nest();
1206
        if (strpos($themeBaseDir, BASE_PATH) === 0) {
1207
            $themeBaseDir = substr($themeBaseDir, strlen(BASE_PATH));
1208
        }
1209
        SSViewer::config()->update('theme_enabled', true);
1210
        SSViewer::set_themes([$themeBaseDir . '/themes/' . $theme, '$default']);
1211
1212
        try {
1213
            $callback();
1214
        } finally {
1215
            Config::unnest();
1216
        }
1217
    }
1218
1219
    /**
1220
     * Get fixture paths for this test
1221
     *
1222
     * @return array List of paths
1223
     */
1224
    protected function getFixturePaths() : array
1225
    {
1226
        $fixtureFile = static::get_fixture_file();
1227
        if (empty($fixtureFile)) {
1228
            return [];
1229
        }
1230
1231
        $fixtureFiles = is_array($fixtureFile) ? $fixtureFile : [$fixtureFile];
1232
1233
        return array_map(function ($fixtureFilePath) {
1234
            return $this->resolveFixturePath($fixtureFilePath);
1235
        }, $fixtureFiles);
1236
    }
1237
1238
    /**
1239
     * Return all extra objects to scaffold for this test
1240
     * @return array
1241
     */
1242
    public static function getExtraDataObjects()
1243
    {
1244
        return static::$extra_dataobjects;
1245
    }
1246
1247
    /**
1248
     * Get additional controller classes to register routes for
1249
     *
1250
     * @return array
1251
     */
1252
    public static function getExtraControllers() : array
1253
    {
1254
        return static::$extra_controllers;
1255
    }
1256
1257
    /**
1258
     * Map a fixture path to a physical file
1259
     *
1260
     * @param string $fixtureFilePath
1261
     * @return string
1262
     */
1263
    protected function resolveFixturePath(string $fixtureFilePath) : string
1264
    {
1265
        // support loading via composer name path.
1266
        if (strpos($fixtureFilePath, ':') !== false) {
1267
            return ModuleResourceLoader::singleton()->resolvePath($fixtureFilePath);
1268
        }
1269
1270
        // Support fixture paths relative to the test class, rather than relative to webroot
1271
        // String checking is faster than file_exists() calls.
1272
        $resolvedPath = realpath($this->getCurrentAbsolutePath() . '/' . $fixtureFilePath);
1273
        if ($resolvedPath) {
1274
            return $resolvedPath;
1275
        }
1276
1277
        // Check if file exists relative to base dir
1278
        $resolvedPath = realpath(Director::baseFolder() . '/' . $fixtureFilePath);
1279
        if ($resolvedPath) {
1280
            return $resolvedPath;
1281
        }
1282
1283
        return $fixtureFilePath;
1284
    }
1285
1286
    protected function setUpRoutes()
1287
    {
1288
        // Get overridden routes
1289
        $rules = $this->getExtraRoutes();
1290
1291
        // Add all other routes
1292
        foreach (Director::config()->uninherited('rules') as $route => $rule) {
1293
            if (!isset($rules[$route])) {
1294
                $rules[$route] = $rule;
1295
            }
1296
        }
1297
1298
        // Add default catch-all rule
1299
        $rules['$Controller//$Action/$ID/$OtherID'] = '*';
1300
1301
        // Add controller-name auto-routing
1302
        Director::config()->set('rules', $rules);
1303
    }
1304
1305
    /**
1306
     * Get extra routes to merge into Director.rules
1307
     *
1308
     * @return array
1309
     */
1310
    protected function getExtraRoutes()
1311
    {
1312
        $rules = [];
1313
        foreach ($this->getExtraControllers() as $class) {
1314
            $controllerInst = Controller::singleton($class);
1315
            $link = Director::makeRelative($controllerInst->Link());
1316
            $route = rtrim($link, '/') . '//$Action/$ID/$OtherID';
1317
            $rules[$route] = $class;
1318
        }
1319
        return $rules;
1320
    }
1321
}
1322