Passed
Pull Request — 4 (#10028)
by Steve
08:25
created

SapphireTest   F

Complexity

Total Complexity 127

Size/Duplication

Total Lines 1264
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 330
c 2
b 0
f 0
dl 0
loc 1264
rs 2
wmc 127

How to fix   Complexity   

Complex Class

Complex classes like SapphireTest often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SapphireTest, and based on these observations, apply Extract Interface, too.

1
<?php
2
namespace SilverStripe\Dev;
3
4
use Exception;
5
use LogicException;
6
use PHPUnit_Framework_Constraint_Not;
7
use PHPUnit_Extensions_GroupTestSuite;
8
use PHPUnit_Framework_Error;
9
use PHPUnit_Framework_Error_Warning;
10
use PHPUnit_Framework_Error_Notice;
11
use PHPUnit_Framework_Error_Deprecation;
12
use PHPUnit_Framework_TestCase;
13
use PHPUnit_Util_InvalidArgumentHelper;
14
use PHPUnit\Framework\Constraint\LogicalNot;
15
use PHPUnit\Framework\Constraint\IsEqualCanonicalizing;
16
use PHPUnit\Framework\TestCase;
17
use PHPUnit\Framework\Exception as PHPUnitFrameworkException;
18
use PHPUnit\Util\Test as TestUtil;
19
use SilverStripe\CMS\Controllers\RootURLController;
20
use SilverStripe\Control\CLIRequestBuilder;
21
use SilverStripe\Control\Controller;
22
use SilverStripe\Control\Cookie;
23
use SilverStripe\Control\Director;
24
use SilverStripe\Control\Email\Email;
25
use SilverStripe\Control\Email\Mailer;
26
use SilverStripe\Control\HTTPApplication;
27
use SilverStripe\Control\HTTPRequest;
28
use SilverStripe\Core\Config\Config;
29
use SilverStripe\Core\Injector\Injector;
30
use SilverStripe\Core\Injector\InjectorLoader;
31
use SilverStripe\Core\Manifest\ClassLoader;
32
use SilverStripe\Core\Manifest\ModuleResourceLoader;
33
use SilverStripe\Dev\Constraint\SSListContains;
34
use SilverStripe\Dev\Constraint\SSListContainsOnly;
35
use SilverStripe\Dev\Constraint\SSListContainsOnlyMatchingItems;
36
use SilverStripe\Dev\State\FixtureTestState;
37
use SilverStripe\Dev\State\SapphireTestState;
38
use SilverStripe\i18n\i18n;
39
use SilverStripe\ORM\Connect\TempDatabase;
40
use SilverStripe\ORM\DataObject;
41
use SilverStripe\ORM\FieldType\DBDatetime;
42
use SilverStripe\ORM\FieldType\DBField;
43
use SilverStripe\ORM\SS_List;
44
use SilverStripe\Security\Group;
45
use SilverStripe\Security\IdentityStore;
46
use SilverStripe\Security\Member;
47
use SilverStripe\Security\Permission;
48
use SilverStripe\Security\Security;
49
use SilverStripe\View\SSViewer;
50
51
/* -------------------------------------------------
52
 *
53
 * This version of SapphireTest is for phpunit 9
54
 * The phpunit 5 version is lower down in this file
55
 * phpunit 6, 7 and 8 are not supported
56
 *
57
 * Why there are two versions of SapphireTest:
58
 * - phpunit 5 is not compatible with php 8
59
 * - a mimimum versin of php 7.3 is required for phpunit 9
60
 * - framework still supports php 7.1 + 7.2 so we need to support both versions for a while
61
 *
62
 * Once php 7.3 is the minimum version required by framework, the phpunit5 versions of SapphireTest
63
 * and FunctionalTest could be removed, along with the if(class_exists()) checks in this file
64
 * However, we still may choose to keep both until php 8 is the minimum to give projects more
65
 * time to upgrade their unit tests to be phpunit 9 compatible
66
 *
67
 * The used on `if(class_exists()` and indentation ensure the the phpunit 5 version function
68
 * signature is used by the php interprester. This is required because the phpunit5
69
 * signature for `setUp()` has no return type while the phpunit 9 version has `setUp(): void`
70
 * Return type covariance allows more specific return types to be defined, while contravariance
71
 * of return types of more abstract return types is not supported
72
 *
73
 * IsEqualCanonicalizing::class is a new class added in phpunit 9, testing that this class exists
74
 * to ensure that we're not using a a prior, incompatible version of php
75
 *
76
 * -------------------------------------------------
77
 */
78
if (class_exists(IsEqualCanonicalizing::class)) {
79
80
    /**
81
     * Test case class for the Sapphire framework.
82
     * Sapphire unit testing is based on PHPUnit, but provides a number of hooks into our data model that make it easier
83
     * to work with.
84
     *
85
     * This class should not be used anywhere outside of unit tests, as phpunit may not be installed
86
     * in production sites.
87
     */
88
    // Ignore multiple classes in same file
89
    // @codingStandardsIgnoreStart
90
    class SapphireTest extends TestCase implements TestOnly
91
    {
92
        // @codingStandardsIgnoreEnd
93
        /**
94
         * Path to fixture data for this test run.
95
         * If passed as an array, multiple fixture files will be loaded.
96
         * Please note that you won't be able to refer with "=>" notation
97
         * between the fixtures, they act independent of each other.
98
         *
99
         * @var string|array
100
         */
101
        protected static $fixture_file = null;
102
103
        /**
104
         * @deprecated 4.0..5.0 Use FixtureTestState instead
105
         * @var FixtureFactory
106
         */
107
        protected $fixtureFactory;
108
109
        /**
110
         * @var Boolean If set to TRUE, this will force a test database to be generated
111
         * in {@link setUp()}. Note that this flag is overruled by the presence of a
112
         * {@link $fixture_file}, which always forces a database build.
113
         *
114
         * @var bool
115
         */
116
        protected $usesDatabase = null;
117
118
        /**
119
         * This test will cleanup its state via transactions.
120
         * If set to false a full schema is forced between tests, but at a performance cost.
121
         *
122
         * @var bool
123
         */
124
        protected $usesTransactions = true;
125
126
        /**
127
         * @var bool
128
         */
129
        protected static $is_running_test = false;
130
131
        /**
132
         * By default, setUp() does not require default records. Pass
133
         * class names in here, and the require/augment default records
134
         * function will be called on them.
135
         *
136
         * @var array
137
         */
138
        protected $requireDefaultRecordsFrom = [];
139
140
        /**
141
         * A list of extensions that can't be applied during the execution of this run.  If they are
142
         * applied, they will be temporarily removed and a database migration called.
143
         *
144
         * The keys of the are the classes that the extensions can't be applied the extensions to, and
145
         * the values are an array of illegal extensions on that class.
146
         *
147
         * Set a class to `*` to remove all extensions (unadvised)
148
         *
149
         * @var array
150
         */
151
        protected static $illegal_extensions = [];
152
153
        /**
154
         * A list of extensions that must be applied during the execution of this run.  If they are
155
         * not applied, they will be temporarily added and a database migration called.
156
         *
157
         * The keys of the are the classes to apply the extensions to, and the values are an array
158
         * of required extensions on that class.
159
         *
160
         * Example:
161
         * <code>
162
         * array("MyTreeDataObject" => array("Versioned", "Hierarchy"))
163
         * </code>
164
         *
165
         * @var array
166
         */
167
        protected static $required_extensions = [];
168
169
        /**
170
         * By default, the test database won't contain any DataObjects that have the interface TestOnly.
171
         * This variable lets you define additional TestOnly DataObjects to set up for this test.
172
         * Set it to an array of DataObject subclass names.
173
         *
174
         * @var array
175
         */
176
        protected static $extra_dataobjects = [];
177
178
        /**
179
         * List of class names of {@see Controller} objects to register routes for
180
         * Controllers must implement Link() method
181
         *
182
         * @var array
183
         */
184
        protected static $extra_controllers = [];
185
186
        /**
187
         * We need to disabling backing up of globals to avoid overriding
188
         * the few globals SilverStripe relies on, like $lang for the i18n subsystem.
189
         *
190
         * @see http://sebastian-bergmann.de/archives/797-Global-Variables-and-PHPUnit.html
191
         */
192
        protected $backupGlobals = false;
193
194
        /**
195
         * State management container for SapphireTest
196
         *
197
         * @var SapphireTestState
198
         */
199
        protected static $state = null;
200
201
        /**
202
         * Temp database helper
203
         *
204
         * @var TempDatabase
205
         */
206
        protected static $tempDB = null;
207
208
        /**
209
         * @return TempDatabase
210
         */
211
        public static function tempDB()
212
        {
213
            if (!class_exists(TempDatabase::class)) {
214
                return null;
215
            }
216
217
            if (!static::$tempDB) {
218
                static::$tempDB = TempDatabase::create();
219
            }
220
            return static::$tempDB;
221
        }
222
223
        /**
224
         * Gets illegal extensions for this class
225
         *
226
         * @return array
227
         */
228
        public static function getIllegalExtensions()
229
        {
230
            return static::$illegal_extensions;
231
        }
232
233
        /**
234
         * Gets required extensions for this class
235
         *
236
         * @return array
237
         */
238
        public static function getRequiredExtensions()
239
        {
240
            return static::$required_extensions;
241
        }
242
243
        /**
244
         * Check if test bootstrapping has been performed. Must not be relied on
245
         * outside of unit tests.
246
         *
247
         * @return bool
248
         */
249
        protected static function is_running_test()
250
        {
251
            return self::$is_running_test;
252
        }
253
254
        /**
255
         * Set test running state
256
         *
257
         * @param bool $bool
258
         */
259
        protected static function set_is_running_test($bool)
260
        {
261
            self::$is_running_test = $bool;
262
        }
263
264
        /**
265
         * @return String
266
         */
267
        public static function get_fixture_file()
268
        {
269
            return static::$fixture_file;
270
        }
271
272
        /**
273
         * @return bool
274
         */
275
        public function getUsesDatabase()
276
        {
277
            return $this->usesDatabase;
278
        }
279
280
        /**
281
         * @return bool
282
         */
283
        public function getUsesTransactions()
284
        {
285
            return $this->usesTransactions;
286
        }
287
288
        /**
289
         * @return array
290
         */
291
        public function getRequireDefaultRecordsFrom()
292
        {
293
            return $this->requireDefaultRecordsFrom;
294
        }
295
296
        /**
297
         * Setup  the test.
298
         * Always sets up in order:
299
         *  - Reset php state
300
         *  - Nest
301
         *  - Custom state helpers
302
         *
303
         * User code should call parent::setUp() before custom setup code
304
         */
305
        protected function setUp(): void
306
        {
307
            if (!defined('FRAMEWORK_PATH')) {
308
                trigger_error(
309
                    'Missing constants, did you remember to include the test bootstrap in your phpunit.xml file?',
310
                    E_USER_WARNING
311
                );
312
            }
313
314
            // Call state helpers
315
            static::$state->setUp($this);
316
317
            // We cannot run the tests on this abstract class.
318
            if (static::class == __CLASS__) {
319
                $this->markTestSkipped(sprintf('Skipping %s ', static::class));
320
            }
321
322
            // i18n needs to be set to the defaults or tests fail
323
            if (class_exists(i18n::class)) {
324
                i18n::set_locale(i18n::config()->uninherited('default_locale'));
325
            }
326
327
            // Set default timezone consistently to avoid NZ-specific dependencies
328
            date_default_timezone_set('UTC');
329
330
            if (class_exists(Member::class)) {
331
                Member::set_password_validator(null);
332
            }
333
334
            if (class_exists(Cookie::class)) {
335
                Cookie::config()->update('report_errors', false);
336
            }
337
338
            if (class_exists(RootURLController::class)) {
339
                RootURLController::reset();
340
            }
341
342
            if (class_exists(Security::class)) {
343
                Security::clear_database_is_ready();
344
            }
345
346
            // Set up test routes
347
            $this->setUpRoutes();
348
349
            $fixtureFiles = $this->getFixturePaths();
350
351
            if ($this->shouldSetupDatabaseForCurrentTest($fixtureFiles)) {
352
                // Assign fixture factory to deprecated prop in case old tests use it over the getter
353
                /** @var FixtureTestState $fixtureState */
354
                $fixtureState = static::$state->getStateByName('fixtures');
355
                $this->fixtureFactory = $fixtureState->getFixtureFactory(static::class);
356
357
                $this->logInWithPermission('ADMIN');
358
            }
359
360
            // turn off template debugging
361
            if (class_exists(SSViewer::class)) {
362
                SSViewer::config()->update('source_file_comments', false);
363
            }
364
365
            // Set up the test mailer
366
            if (class_exists(TestMailer::class)) {
367
                Injector::inst()->registerService(new TestMailer(), Mailer::class);
368
            }
369
370
            if (class_exists(Email::class)) {
371
                Email::config()->remove('send_all_emails_to');
372
                Email::config()->remove('send_all_emails_from');
373
                Email::config()->remove('cc_all_emails_to');
374
                Email::config()->remove('bcc_all_emails_to');
375
            }
376
        }
377
378
379
        /**
380
         * Helper method to determine if the current test should enable a test database
381
         *
382
         * @param $fixtureFiles
383
         * @return bool
384
         */
385
        protected function shouldSetupDatabaseForCurrentTest($fixtureFiles)
386
        {
387
            $databaseEnabledByDefault = $fixtureFiles || $this->usesDatabase;
388
389
            return ($databaseEnabledByDefault && !$this->currentTestDisablesDatabase())
390
                || $this->currentTestEnablesDatabase();
391
        }
392
393
        /**
394
         * Helper method to check, if the current test uses the database.
395
         * This can be switched on with the annotation "@useDatabase"
396
         *
397
         * @return bool
398
         */
399
        protected function currentTestEnablesDatabase()
400
        {
401
            $annotations = $this->getAnnotations();
402
403
            return array_key_exists('useDatabase', $annotations['method'])
404
                && $annotations['method']['useDatabase'][0] !== 'false';
405
        }
406
407
        /**
408
         * Helper method to check, if the current test uses the database.
409
         * This can be switched on with the annotation "@useDatabase false"
410
         *
411
         * @return bool
412
         */
413
        protected function currentTestDisablesDatabase()
414
        {
415
            $annotations = $this->getAnnotations();
416
417
            return array_key_exists('useDatabase', $annotations['method'])
418
                && $annotations['method']['useDatabase'][0] === 'false';
419
        }
420
421
        /**
422
         * Called once per test case ({@link SapphireTest} subclass).
423
         * This is different to {@link setUp()}, which gets called once
424
         * per method. Useful to initialize expensive operations which
425
         * don't change state for any called method inside the test,
426
         * e.g. dynamically adding an extension. See {@link teardownAfterClass()}
427
         * for tearing down the state again.
428
         *
429
         * Always sets up in order:
430
         *  - Reset php state
431
         *  - Nest
432
         *  - Custom state helpers
433
         *
434
         * User code should call parent::setUpBeforeClass() before custom setup code
435
         *
436
         * @throws Exception
437
         */
438
        public static function setUpBeforeClass(): void
439
        {
440
            // Start tests
441
            static::start();
442
443
            if (!static::$state) {
444
                throw new Exception('SapphireTest failed to bootstrap!');
445
            }
446
447
            // Call state helpers
448
            static::$state->setUpOnce(static::class);
449
450
            // Build DB if we have objects
451
            if (class_exists(DataObject::class) && static::getExtraDataObjects()) {
452
                DataObject::reset();
453
                static::resetDBSchema(true, true);
454
            }
455
        }
456
457
        /**
458
         * tearDown method that's called once per test class rather once per test method.
459
         *
460
         * Always sets up in order:
461
         *  - Custom state helpers
462
         *  - Unnest
463
         *  - Reset php state
464
         *
465
         * User code should call parent::tearDownAfterClass() after custom tear down code
466
         */
467
        public static function tearDownAfterClass(): void
468
        {
469
            // Call state helpers
470
            static::$state->tearDownOnce(static::class);
471
472
            // Reset DB schema
473
            static::resetDBSchema();
474
        }
475
476
        /**
477
         * @return FixtureFactory|false
478
         * @deprecated 4.0.0:5.0.0
479
         */
480
        public function getFixtureFactory()
481
        {
482
            Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
483
            /** @var FixtureTestState $state */
484
            $state = static::$state->getStateByName('fixtures');
485
            return $state->getFixtureFactory(static::class);
486
        }
487
488
        /**
489
         * Sets a new fixture factory
490
         * @param FixtureFactory $factory
491
         * @return $this
492
         * @deprecated 4.0.0:5.0.0
493
         */
494
        public function setFixtureFactory(FixtureFactory $factory)
495
        {
496
            Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
497
            /** @var FixtureTestState $state */
498
            $state = static::$state->getStateByName('fixtures');
499
            $state->setFixtureFactory($factory, static::class);
500
            $this->fixtureFactory = $factory;
501
            return $this;
502
        }
503
504
        /**
505
         * Get the ID of an object from the fixture.
506
         *
507
         * @param string $className The data class or table name, as specified in your fixture file.  Parent classes won't work
508
         * @param string $identifier The identifier string, as provided in your fixture file
509
         * @return int
510
         */
511
        protected function idFromFixture($className, $identifier)
512
        {
513
            /** @var FixtureTestState $state */
514
            $state = static::$state->getStateByName('fixtures');
515
            $id = $state->getFixtureFactory(static::class)->getId($className, $identifier);
516
517
            if (!$id) {
518
                throw new InvalidArgumentException(sprintf(
519
                    "Couldn't find object '%s' (class: %s)",
520
                    $identifier,
521
                    $className
522
                ));
523
            }
524
525
            return $id;
526
        }
527
528
        /**
529
         * Return all of the IDs in the fixture of a particular class name.
530
         * Will collate all IDs form all fixtures if multiple fixtures are provided.
531
         *
532
         * @param string $className The data class or table name, as specified in your fixture file
533
         * @return array A map of fixture-identifier => object-id
534
         */
535
        protected function allFixtureIDs($className)
536
        {
537
            /** @var FixtureTestState $state */
538
            $state = static::$state->getStateByName('fixtures');
539
            return $state->getFixtureFactory(static::class)->getIds($className);
540
        }
541
542
        /**
543
         * Get an object from the fixture.
544
         *
545
         * @param string $className The data class or table name, as specified in your fixture file. Parent classes won't work
546
         * @param string $identifier The identifier string, as provided in your fixture file
547
         *
548
         * @return DataObject
549
         */
550
        protected function objFromFixture($className, $identifier)
551
        {
552
            /** @var FixtureTestState $state */
553
            $state = static::$state->getStateByName('fixtures');
554
            $obj = $state->getFixtureFactory(static::class)->get($className, $identifier);
555
556
            if (!$obj) {
557
                throw new InvalidArgumentException(sprintf(
558
                    "Couldn't find object '%s' (class: %s)",
559
                    $identifier,
560
                    $className
561
                ));
562
            }
563
564
            return $obj;
565
        }
566
567
        /**
568
         * Load a YAML fixture file into the database.
569
         * Once loaded, you can use idFromFixture() and objFromFixture() to get items from the fixture.
570
         * Doesn't clear existing fixtures.
571
         * @param string $fixtureFile The location of the .yml fixture file, relative to the site base dir
572
         * @deprecated 4.0.0:5.0.0
573
         *
574
         */
575
        public function loadFixture($fixtureFile)
576
        {
577
            Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
578
            $fixture = Injector::inst()->create(YamlFixture::class, $fixtureFile);
579
            $fixture->writeInto($this->getFixtureFactory());
580
        }
581
582
        /**
583
         * Clear all fixtures which were previously loaded through
584
         * {@link loadFixture()}
585
         */
586
        public function clearFixtures()
587
        {
588
            /** @var FixtureTestState $state */
589
            $state = static::$state->getStateByName('fixtures');
590
            $state->getFixtureFactory(static::class)->clear();
591
        }
592
593
        /**
594
         * Useful for writing unit tests without hardcoding folder structures.
595
         *
596
         * @return string Absolute path to current class.
597
         */
598
        protected function getCurrentAbsolutePath()
599
        {
600
            $filename = ClassLoader::inst()->getItemPath(static::class);
601
            if (!$filename) {
602
                throw new LogicException('getItemPath returned null for ' . static::class
603
                    . '. Try adding flush=1 to the test run.');
604
            }
605
            return dirname($filename);
606
        }
607
608
        /**
609
         * @return string File path relative to webroot
610
         */
611
        protected function getCurrentRelativePath()
612
        {
613
            $base = Director::baseFolder();
614
            $path = $this->getCurrentAbsolutePath();
615
            if (substr($path, 0, strlen($base)) == $base) {
616
                $path = preg_replace('/^\/*/', '', substr($path, strlen($base)));
617
            }
618
            return $path;
619
        }
620
621
        /**
622
         * Setup  the test.
623
         * Always sets up in order:
624
         *  - Custom state helpers
625
         *  - Unnest
626
         *  - Reset php state
627
         *
628
         * User code should call parent::tearDown() after custom tear down code
629
         */
630
        protected function tearDown(): void
631
        {
632
            // Reset mocked datetime
633
            if (class_exists(DBDatetime::class)) {
634
                DBDatetime::clear_mock_now();
635
            }
636
637
            // Stop the redirection that might have been requested in the test.
638
            // Note: Ideally a clean Controller should be created for each test.
639
            // Now all tests executed in a batch share the same controller.
640
            if (class_exists(Controller::class)) {
641
                $controller = Controller::has_curr() ? Controller::curr() : null;
642
                if ($controller && ($response = $controller->getResponse()) && $response->getHeader('Location')) {
643
                    $response->setStatusCode(200);
644
                    $response->removeHeader('Location');
645
                }
646
            }
647
648
            // Call state helpers
649
            static::$state->tearDown($this);
650
        }
651
652
        // public static function assertContains(
653
        //     $needle,
654
        //     $haystack,
655
        //     $message = '',
656
        //     $ignoreCase = false,
657
        //     $checkForObjectIdentity = true,
658
        //     $checkForNonObjectIdentity = false
659
        // ): void {
660
        //     if ($haystack instanceof DBField) {
661
        //         $haystack = (string)$haystack;
662
        //     }
663
        //     if (is_iterable($haystack)) {
664
        //         $strict = is_object($needle) ? $checkForObjectIdentity : $checkForNonObjectIdentity;
665
        //         if ($strict) {
666
        //             parent::assertContains($needle, $haystack, $message);
667
        //         } else {
668
        //             parent::assertContainsEquals($needle, $haystack, $message);
669
        //         }
670
        //     } else {
671
        //         static::assertContainsNonIterable($needle, $haystack, $message, $ignoreCase);
672
        //     }
673
        // }
674
675
        // public static function assertContainsNonIterable(
676
        //     $needle,
677
        //     $haystack,
678
        //     $message = '',
679
        //     $ignoreCase = false
680
        // ): void {
681
        //     if ($haystack instanceof DBField) {
682
        //         $haystack = (string)$haystack;
683
        //     }
684
        //     if ($ignoreCase) {
685
        //         parent::assertStringContainsStringIgnoringCase($needle, $haystack, $message);
686
        //     } else {
687
        //         parent::assertStringContainsString($needle, $haystack, $message);
688
        //     }
689
        // }
690
691
        // public static function assertNotContains(
692
        //     $needle,
693
        //     $haystack,
694
        //     $message = '',
695
        //     $ignoreCase = false,
696
        //     $checkForObjectIdentity = true,
697
        //     $checkForNonObjectIdentity = false
698
        // ): void {
699
        //     if ($haystack instanceof DBField) {
700
        //         $haystack = (string)$haystack;
701
        //     }
702
        //     if (is_iterable($haystack)) {
703
        //         $strict = is_object($needle) ? $checkForObjectIdentity : $checkForNonObjectIdentity;
704
        //         if ($strict) {
705
        //             parent::assertNotContains($needle, $haystack, $message);
706
        //         } else {
707
        //             parent::assertNotContainsEquals($needle, $haystack, $message);
708
        //         }
709
        //     } else {
710
        //         static::assertNotContainsNonIterable($needle, $haystack, $message, $ignoreCase);
711
        //     }
712
        // }
713
714
        // protected static function assertNotContainsNonIterable(
715
        //     $needle,
716
        //     $haystack,
717
        //     $message = '',
718
        //     $ignoreCase = false
719
        // ): void {
720
        //     if ($haystack instanceof DBField) {
721
        //         $haystack = (string)$haystack;
722
        //     }
723
        //     if ($ignoreCase) {
724
        //         parent::assertStringNotContainsStringIgnoringCase($needle, $haystack, $message);
725
        //     } else {
726
        //         parent::assertStringNotContainsString($needle, $haystack, $message);
727
        //     }
728
        // }
729
730
        // /**
731
        //  * Backwards compatibility for core tests
732
        //  */
733
        // public static function assertInternalType($expected, $actual, $message = '')
734
        // {
735
        //     switch ($expected) {
736
        //         case 'numeric':
737
        //             static::assertIsNumeric($actual, $message);
738
        //             return;
739
        //         case 'integer':
740
        //         case 'int':
741
        //             static::assertIsInt($actual, $message);
742
        //             return;
743
        //         case 'double':
744
        //         case 'float':
745
        //         case 'real':
746
        //             static::assertIsFloat($actual, $message);
747
        //             return;
748
        //         case 'string':
749
        //             static::assertIsString($actual, $message);
750
        //             return;
751
        //         case 'boolean':
752
        //         case 'bool':
753
        //             static::assertIsBool($actual, $message);
754
        //             return;
755
        //         case 'null':
756
        //             static::assertTrue(is_null($actual), $message);
757
        //             return;
758
        //         case 'array':
759
        //             static::assertIsArray($actual, $message);
760
        //             return;
761
        //         case 'object':
762
        //             static::assertIsObject($actual, $message);
763
        //             return;
764
        //         case 'resource':
765
        //             static::assertIsResource($actual, $message);
766
        //             return;
767
        //         case 'resource (closed)':
768
        //             static::assertIsClosedResource($actual, $message);
769
        //             return;
770
        //         case 'scalar':
771
        //             static::assertIsScalar($actual, $message);
772
        //             return;
773
        //         case 'callable':
774
        //             static::assertIsCallable($actual, $message);
775
        //             return;
776
        //         case 'iterable':
777
        //             static::assertIsIterable($actual, $message);
778
        //             return;
779
        //         default:
780
        //             return false;
781
        //     }
782
        // }
783
784
        /**
785
         * Clear the log of emails sent
786
         *
787
         * @return bool True if emails cleared
788
         */
789
        public function clearEmails()
790
        {
791
            /** @var Mailer $mailer */
792
            $mailer = Injector::inst()->get(Mailer::class);
793
            if ($mailer instanceof TestMailer) {
794
                $mailer->clearEmails();
795
                return true;
796
            }
797
            return false;
798
        }
799
800
        /**
801
         * Search for an email that was sent.
802
         * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
803
         * @param string $to
804
         * @param string $from
805
         * @param string $subject
806
         * @param string $content
807
         * @return array|null Contains keys: 'Type', 'To', 'From', 'Subject', 'Content', 'PlainContent', 'AttachedFiles',
808
         *               'HtmlContent'
809
         */
810
        public static function findEmail($to, $from = null, $subject = null, $content = null)
811
        {
812
            /** @var Mailer $mailer */
813
            $mailer = Injector::inst()->get(Mailer::class);
814
            if ($mailer instanceof TestMailer) {
815
                return $mailer->findEmail($to, $from, $subject, $content);
816
            }
817
            return null;
818
        }
819
820
        /**
821
         * Assert that the matching email was sent since the last call to clearEmails()
822
         * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
823
         *
824
         * @param string $to
825
         * @param string $from
826
         * @param string $subject
827
         * @param string $content
828
         */
829
        public static function assertEmailSent($to, $from = null, $subject = null, $content = null)
830
        {
831
            $found = (bool)static::findEmail($to, $from, $subject, $content);
832
833
            $infoParts = '';
834
            $withParts = [];
835
            if ($to) {
836
                $infoParts .= " to '$to'";
837
            }
838
            if ($from) {
839
                $infoParts .= " from '$from'";
840
            }
841
            if ($subject) {
842
                $withParts[] = "subject '$subject'";
843
            }
844
            if ($content) {
845
                $withParts[] = "content '$content'";
846
            }
847
            if ($withParts) {
848
                $infoParts .= ' with ' . implode(' and ', $withParts);
849
            }
850
851
            static::assertTrue(
852
                $found,
853
                "Failed asserting that an email was sent$infoParts."
854
            );
855
        }
856
857
858
        /**
859
         * Assert that the given {@link SS_List} includes DataObjects matching the given key-value
860
         * pairs.  Each match must correspond to 1 distinct record.
861
         *
862
         * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
863
         * either pass a single pattern or an array of patterns.
864
         * @param SS_List $list The {@link SS_List} to test.
865
         * @param string $message
866
         *
867
         * Examples
868
         * --------
869
         * Check that $members includes an entry with Email = [email protected]:
870
         *      $this->assertListContains(['Email' => '[email protected]'], $members);
871
         *
872
         * Check that $members includes entries with Email = [email protected] and with
873
         * Email = [email protected]:
874
         *      $this->assertListContains([
875
         *         ['Email' => '[email protected]'],
876
         *         ['Email' => '[email protected]'],
877
         *      ], $members);
878
         */
879
        public static function assertListContains($matches, SS_List $list, $message = '')
880
        {
881
            if (!is_array($matches)) {
882
                throw self::createInvalidArgumentException(
883
                    1,
884
                    'array'
885
                );
886
            }
887
888
            static::assertThat(
889
                $list,
890
                new SSListContains(
891
                    $matches
892
                ),
893
                $message
894
            );
895
        }
896
897
        /**
898
         * @param $matches
899
         * @param $dataObjectSet
900
         * @deprecated 4.0.0:5.0.0 Use assertListContains() instead
901
         *
902
         */
903
        public function assertDOSContains($matches, $dataObjectSet)
904
        {
905
            Deprecation::notice('5.0', 'Use assertListContains() instead');
906
            static::assertListContains($matches, $dataObjectSet);
907
        }
908
909
        /**
910
         * Asserts that no items in a given list appear in the given dataobject list
911
         *
912
         * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
913
         * either pass a single pattern or an array of patterns.
914
         * @param SS_List $list The {@link SS_List} to test.
915
         * @param string $message
916
         *
917
         * Examples
918
         * --------
919
         * Check that $members doesn't have an entry with Email = [email protected]:
920
         *      $this->assertListNotContains(['Email' => '[email protected]'], $members);
921
         *
922
         * Check that $members doesn't have entries with Email = [email protected] and with
923
         * Email = [email protected]:
924
         *      $this->assertListNotContains([
925
         *          ['Email' => '[email protected]'],
926
         *          ['Email' => '[email protected]'],
927
         *      ], $members);
928
         */
929
        public static function assertListNotContains($matches, SS_List $list, $message = '')
930
        {
931
            if (!is_array($matches)) {
932
                throw self::createInvalidArgumentException(
933
                    1,
934
                    'array'
935
                );
936
            }
937
938
            $constraint = new LogicalNot(
939
                new SSListContains(
940
                    $matches
941
                )
942
            );
943
944
            static::assertThat(
945
                $list,
946
                $constraint,
947
                $message
948
            );
949
        }
950
951
        /**
952
         * @param $matches
953
         * @param $dataObjectSet
954
         * @deprecated 4.0.0:5.0.0 Use assertListNotContains() instead
955
         *
956
         */
957
        public static function assertNotDOSContains($matches, $dataObjectSet)
958
        {
959
            Deprecation::notice('5.0', 'Use assertListNotContains() instead');
960
            static::assertListNotContains($matches, $dataObjectSet);
961
        }
962
963
        /**
964
         * Assert that the given {@link SS_List} includes only DataObjects matching the given
965
         * key-value pairs.  Each match must correspond to 1 distinct record.
966
         *
967
         * Example
968
         * --------
969
         * Check that *only* the entries Sam Minnee and Ingo Schommer exist in $members.  Order doesn't
970
         * matter:
971
         *     $this->assertListEquals([
972
         *        ['FirstName' =>'Sam', 'Surname' => 'Minnee'],
973
         *        ['FirstName' => 'Ingo', 'Surname' => 'Schommer'],
974
         *      ], $members);
975
         *
976
         * @param mixed $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
977
         * either pass a single pattern or an array of patterns.
978
         * @param mixed $list The {@link SS_List} to test.
979
         * @param string $message
980
         */
981
        public static function assertListEquals($matches, SS_List $list, $message = '')
982
        {
983
            if (!is_array($matches)) {
984
                throw self::createInvalidArgumentException(
985
                    1,
986
                    'array'
987
                );
988
            }
989
990
            static::assertThat(
991
                $list,
992
                new SSListContainsOnly(
993
                    $matches
994
                ),
995
                $message
996
            );
997
        }
998
999
        /**
1000
         * @param $matches
1001
         * @param SS_List $dataObjectSet
1002
         * @deprecated 4.0.0:5.0.0 Use assertListEquals() instead
1003
         *
1004
         */
1005
        public function assertDOSEquals($matches, $dataObjectSet)
1006
        {
1007
            Deprecation::notice('5.0', 'Use assertListEquals() instead');
1008
            static::assertListEquals($matches, $dataObjectSet);
1009
        }
1010
1011
1012
        /**
1013
         * Assert that the every record in the given {@link SS_List} matches the given key-value
1014
         * pairs.
1015
         *
1016
         * Example
1017
         * --------
1018
         * Check that every entry in $members has a Status of 'Active':
1019
         *     $this->assertListAllMatch(['Status' => 'Active'], $members);
1020
         *
1021
         * @param mixed $match The pattern to match.  The pattern is a map of key-value pairs.
1022
         * @param mixed $list The {@link SS_List} to test.
1023
         * @param string $message
1024
         */
1025
        public static function assertListAllMatch($match, SS_List $list, $message = '')
1026
        {
1027
            if (!is_array($match)) {
1028
                throw self::createInvalidArgumentException(
1029
                    1,
1030
                    'array'
1031
                );
1032
            }
1033
1034
            static::assertThat(
1035
                $list,
1036
                new SSListContainsOnlyMatchingItems(
1037
                    $match
1038
                ),
1039
                $message
1040
            );
1041
        }
1042
1043
        /**
1044
         * @param $match
1045
         * @param SS_List $dataObjectSet
1046
         * @deprecated 4.0.0:5.0.0 Use assertListAllMatch() instead
1047
         *
1048
         */
1049
        public function assertDOSAllMatch($match, SS_List $dataObjectSet)
1050
        {
1051
            Deprecation::notice('5.0', 'Use assertListAllMatch() instead');
1052
            static::assertListAllMatch($match, $dataObjectSet);
1053
        }
1054
1055
        /**
1056
         * Removes sequences of repeated whitespace characters from SQL queries
1057
         * making them suitable for string comparison
1058
         *
1059
         * @param string $sql
1060
         * @return string The cleaned and normalised SQL string
1061
         */
1062
        protected static function normaliseSQL($sql)
1063
        {
1064
            return trim(preg_replace('/\s+/m', ' ', $sql));
1065
        }
1066
1067
        /**
1068
         * Asserts that two SQL queries are equivalent
1069
         *
1070
         * @param string $expectedSQL
1071
         * @param string $actualSQL
1072
         * @param string $message
1073
         * @param float|int $delta
1074
         * @param integer $maxDepth
1075
         * @param boolean $canonicalize
1076
         * @param boolean $ignoreCase
1077
         */
1078
        public static function assertSQLEquals(
1079
            $expectedSQL,
1080
            $actualSQL,
1081
            $message = ''
1082
        ) {
1083
            // Normalise SQL queries to remove patterns of repeating whitespace
1084
            $expectedSQL = static::normaliseSQL($expectedSQL);
1085
            $actualSQL = static::normaliseSQL($actualSQL);
1086
1087
            static::assertEquals($expectedSQL, $actualSQL, $message);
1088
        }
1089
1090
        /**
1091
         * Asserts that a SQL query contains a SQL fragment
1092
         *
1093
         * @param string $needleSQL
1094
         * @param string $haystackSQL
1095
         * @param string $message
1096
         * @param boolean $ignoreCase
1097
         * @param boolean $checkForObjectIdentity
1098
         */
1099
        public static function assertSQLContains(
1100
            $needleSQL,
1101
            $haystackSQL,
1102
            $message = '',
1103
            $ignoreCase = false,
1104
            $checkForObjectIdentity = true
1105
        ) {
1106
            $needleSQL = static::normaliseSQL($needleSQL);
1107
            $haystackSQL = static::normaliseSQL($haystackSQL);
1108
            if (is_iterable($haystackSQL)) {
1109
                /** @var iterable $iterableHaystackSQL */
1110
                $iterableHaystackSQL = $haystackSQL;
1111
                static::assertContains($needleSQL, $iterableHaystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
1112
            } else {
1113
                static::assertContainsNonIterable($needleSQL, $haystackSQL, $message, $ignoreCase);
1114
            }
1115
        }
1116
1117
        /**
1118
         * Asserts that a SQL query contains a SQL fragment
1119
         *
1120
         * @param string $needleSQL
1121
         * @param string $haystackSQL
1122
         * @param string $message
1123
         * @param boolean $ignoreCase
1124
         * @param boolean $checkForObjectIdentity
1125
         */
1126
        public static function assertSQLNotContains(
1127
            $needleSQL,
1128
            $haystackSQL,
1129
            $message = '',
1130
            $ignoreCase = false,
1131
            $checkForObjectIdentity = true
1132
        ) {
1133
            $needleSQL = static::normaliseSQL($needleSQL);
1134
            $haystackSQL = static::normaliseSQL($haystackSQL);
1135
            if (is_iterable($haystackSQL)) {
1136
                /** @var iterable $iterableHaystackSQL */
1137
                $iterableHaystackSQL = $haystackSQL;
1138
                static::assertNotContains($needleSQL, $iterableHaystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
1139
            } else {
1140
                static::assertNotContainsNonIterable($needleSQL, $haystackSQL, $message, $ignoreCase);
1141
            }
1142
        }
1143
1144
        /**
1145
         * Start test environment
1146
         */
1147
        public static function start()
1148
        {
1149
            if (static::is_running_test()) {
1150
                return;
1151
            }
1152
1153
            // Health check
1154
            if (InjectorLoader::inst()->countManifests()) {
1155
                throw new LogicException('SapphireTest::start() cannot be called within another application');
1156
            }
1157
            static::set_is_running_test(true);
1158
1159
            // Test application
1160
            $kernel = new TestKernel(BASE_PATH);
1161
1162
            if (class_exists(HTTPApplication::class)) {
1163
                // Mock request
1164
                $_SERVER['argv'] = ['vendor/bin/phpunit', '/'];
1165
                $request = CLIRequestBuilder::createFromEnvironment();
1166
1167
                $app = new HTTPApplication($kernel);
1168
                $flush = array_key_exists('flush', $request->getVars());
1169
1170
                // Custom application
1171
                $res = $app->execute($request, function (HTTPRequest $request) {
1172
                    // Start session and execute
1173
                    $request->getSession()->init($request);
1174
1175
                    // Invalidate classname spec since the test manifest will now pull out new subclasses for each internal class
1176
                    // (e.g. Member will now have various subclasses of DataObjects that implement TestOnly)
1177
                    DataObject::reset();
1178
1179
                    // Set dummy controller;
1180
                    $controller = Controller::create();
1181
                    $controller->setRequest($request);
1182
                    $controller->pushCurrent();
1183
                    $controller->doInit();
1184
                }, $flush);
1185
1186
                if ($res && $res->isError()) {
1187
                    throw new LogicException($res->getBody());
1188
                }
1189
            } else {
1190
                // Allow flush from the command line in the absence of HTTPApplication's special sauce
1191
                $flush = false;
1192
                foreach ($_SERVER['argv'] as $arg) {
1193
                    if (preg_match('/^(--)?flush(=1)?$/', $arg)) {
1194
                        $flush = true;
1195
                    }
1196
                }
1197
                $kernel->boot($flush);
1198
            }
1199
1200
            // Register state
1201
            static::$state = SapphireTestState::singleton();
1202
            // Register temp DB holder
1203
            static::tempDB();
1204
        }
1205
1206
        /**
1207
         * Reset the testing database's schema, but only if it is active
1208
         * @param bool $includeExtraDataObjects If true, the extraDataObjects tables will also be included
1209
         * @param bool $forceCreate Force DB to be created if it doesn't exist
1210
         */
1211
        public static function resetDBSchema($includeExtraDataObjects = false, $forceCreate = false)
1212
        {
1213
            if (!static::$tempDB) {
1214
                return;
1215
            }
1216
1217
            // Check if DB is active before reset
1218
            if (!static::$tempDB->isUsed()) {
1219
                if (!$forceCreate) {
1220
                    return;
1221
                }
1222
                static::$tempDB->build();
1223
            }
1224
            $extraDataObjects = $includeExtraDataObjects ? static::getExtraDataObjects() : [];
1225
            static::$tempDB->resetDBSchema((array)$extraDataObjects);
1226
        }
1227
1228
        /**
1229
         * A wrapper for automatically performing callbacks as a user with a specific permission
1230
         *
1231
         * @param string|array $permCode
1232
         * @param callable $callback
1233
         * @return mixed
1234
         */
1235
        public function actWithPermission($permCode, $callback)
1236
        {
1237
            return Member::actAs($this->createMemberWithPermission($permCode), $callback);
1238
        }
1239
1240
        /**
1241
         * Create Member and Group objects on demand with specific permission code
1242
         *
1243
         * @param string|array $permCode
1244
         * @return Member
1245
         */
1246
        protected function createMemberWithPermission($permCode)
1247
        {
1248
            if (is_array($permCode)) {
1249
                $permArray = $permCode;
1250
                $permCode = implode('.', $permCode);
1251
            } else {
1252
                $permArray = [$permCode];
1253
            }
1254
1255
            // Check cached member
1256
            if (isset($this->cache_generatedMembers[$permCode])) {
1257
                $member = $this->cache_generatedMembers[$permCode];
1258
            } else {
1259
                // Generate group with these permissions
1260
                $group = Group::create();
1261
                $group->Title = "$permCode group";
1262
                $group->write();
1263
1264
                // Create each individual permission
1265
                foreach ($permArray as $permArrayItem) {
1266
                    $permission = Permission::create();
1267
                    $permission->Code = $permArrayItem;
1268
                    $permission->write();
1269
                    $group->Permissions()->add($permission);
1270
                }
1271
1272
                $member = Member::get()->filter([
1273
                    'Email' => "[email protected]",
1274
                ])->first();
1275
                if (!$member) {
1276
                    $member = Member::create();
1277
                }
1278
1279
                $member->FirstName = $permCode;
1280
                $member->Surname = 'User';
1281
                $member->Email = "[email protected]";
1282
                $member->write();
1283
                $group->Members()->add($member);
1284
1285
                $this->cache_generatedMembers[$permCode] = $member;
1286
            }
1287
            return $member;
1288
        }
1289
1290
        /**
1291
         * Create a member and group with the given permission code, and log in with it.
1292
         * Returns the member ID.
1293
         *
1294
         * @param string|array $permCode Either a permission, or list of permissions
1295
         * @return int Member ID
1296
         */
1297
        public function logInWithPermission($permCode = 'ADMIN')
1298
        {
1299
            $member = $this->createMemberWithPermission($permCode);
1300
            $this->logInAs($member);
1301
            return $member->ID;
1302
        }
1303
1304
        /**
1305
         * Log in as the given member
1306
         *
1307
         * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in
1308
         */
1309
        public function logInAs($member)
1310
        {
1311
            if (is_numeric($member)) {
1312
                $member = DataObject::get_by_id(Member::class, $member);
1313
            } elseif (!is_object($member)) {
1314
                $member = $this->objFromFixture(Member::class, $member);
1315
            }
1316
            Injector::inst()->get(IdentityStore::class)->logIn($member);
1317
        }
1318
1319
        /**
1320
         * Log out the current user
1321
         */
1322
        public function logOut()
1323
        {
1324
            /** @var IdentityStore $store */
1325
            $store = Injector::inst()->get(IdentityStore::class);
1326
            $store->logOut();
1327
        }
1328
1329
        /**
1330
         * Cache for logInWithPermission()
1331
         */
1332
        protected $cache_generatedMembers = [];
1333
1334
        /**
1335
         * Test against a theme.
1336
         *
1337
         * @param string $themeBaseDir themes directory
1338
         * @param string $theme Theme name
1339
         * @param callable $callback
1340
         * @throws Exception
1341
         */
1342
        protected function useTestTheme($themeBaseDir, $theme, $callback)
1343
        {
1344
            Config::nest();
1345
            if (strpos($themeBaseDir, BASE_PATH) === 0) {
1346
                $themeBaseDir = substr($themeBaseDir, strlen(BASE_PATH));
1347
            }
1348
            SSViewer::config()->update('theme_enabled', true);
1349
            SSViewer::set_themes([$themeBaseDir . '/themes/' . $theme, '$default']);
1350
1351
            try {
1352
                $callback();
1353
            } finally {
1354
                Config::unnest();
1355
            }
1356
        }
1357
1358
        /**
1359
         * Get fixture paths for this test
1360
         *
1361
         * @return array List of paths
1362
         */
1363
        protected function getFixturePaths()
1364
        {
1365
            $fixtureFile = static::get_fixture_file();
1366
            if (empty($fixtureFile)) {
1367
                return [];
1368
            }
1369
1370
            $fixtureFiles = is_array($fixtureFile) ? $fixtureFile : [$fixtureFile];
1371
1372
            return array_map(function ($fixtureFilePath) {
1373
                return $this->resolveFixturePath($fixtureFilePath);
1374
            }, $fixtureFiles);
1375
        }
1376
1377
        /**
1378
         * Return all extra objects to scaffold for this test
1379
         * @return array
1380
         */
1381
        public static function getExtraDataObjects()
1382
        {
1383
            return static::$extra_dataobjects;
1384
        }
1385
1386
        /**
1387
         * Get additional controller classes to register routes for
1388
         *
1389
         * @return array
1390
         */
1391
        public static function getExtraControllers()
1392
        {
1393
            return static::$extra_controllers;
1394
        }
1395
1396
        /**
1397
         * Map a fixture path to a physical file
1398
         *
1399
         * @param string $fixtureFilePath
1400
         * @return string
1401
         */
1402
        protected function resolveFixturePath($fixtureFilePath)
1403
        {
1404
            // support loading via composer name path.
1405
            if (strpos($fixtureFilePath, ':') !== false) {
1406
                return ModuleResourceLoader::singleton()->resolvePath($fixtureFilePath);
1407
            }
1408
1409
            // Support fixture paths relative to the test class, rather than relative to webroot
1410
            // String checking is faster than file_exists() calls.
1411
            $resolvedPath = realpath($this->getCurrentAbsolutePath() . '/' . $fixtureFilePath);
1412
            if ($resolvedPath) {
1413
                return $resolvedPath;
1414
            }
1415
1416
            // Check if file exists relative to base dir
1417
            $resolvedPath = realpath(Director::baseFolder() . '/' . $fixtureFilePath);
1418
            if ($resolvedPath) {
1419
                return $resolvedPath;
1420
            }
1421
1422
            return $fixtureFilePath;
1423
        }
1424
1425
        protected function setUpRoutes()
1426
        {
1427
            if (!class_exists(Director::class)) {
1428
                return;
1429
            }
1430
1431
            // Get overridden routes
1432
            $rules = $this->getExtraRoutes();
1433
1434
            // Add all other routes
1435
            foreach (Director::config()->uninherited('rules') as $route => $rule) {
1436
                if (!isset($rules[$route])) {
1437
                    $rules[$route] = $rule;
1438
                }
1439
            }
1440
1441
            // Add default catch-all rule
1442
            $rules['$Controller//$Action/$ID/$OtherID'] = '*';
1443
1444
            // Add controller-name auto-routing
1445
            Director::config()->set('rules', $rules);
1446
        }
1447
1448
        /**
1449
         * Get extra routes to merge into Director.rules
1450
         *
1451
         * @return array
1452
         */
1453
        protected function getExtraRoutes()
1454
        {
1455
            $rules = [];
1456
            foreach ($this->getExtraControllers() as $class) {
1457
                $controllerInst = Controller::singleton($class);
1458
                $link = Director::makeRelative($controllerInst->Link());
1459
                $route = rtrim($link, '/') . '//$Action/$ID/$OtherID';
1460
                $rules[$route] = $class;
1461
            }
1462
            return $rules;
1463
        }
1464
1465
        /**
1466
         * Reimplementation of phpunit5 PHPUnit_Util_InvalidArgumentHelper::factory()
1467
         *
1468
         * @param $argument
1469
         * @param $type
1470
         * @param $value
1471
         */
1472
        public static function createInvalidArgumentException($argument, $type, $value = null)
1473
        {
1474
            $stack = debug_backtrace(false);
1475
1476
            return new PHPUnitFrameworkException(
1477
                sprintf(
1478
                    'Argument #%d%sof %s::%s() must be a %s',
1479
                    $argument,
1480
                    $value !== null ? ' (' . gettype($value) . '#' . $value . ')' : ' (No Value) ',
1481
                    $stack[1]['class'],
1482
                    $stack[1]['function'],
1483
                    $type
1484
                )
1485
            );
1486
        }
1487
1488
        /**
1489
         * Returns the annotations for this test.
1490
         *
1491
         * @return array
1492
         */
1493
        public function getAnnotations()
1494
        {
1495
            return TestUtil::parseTestMethodAnnotations(
1496
                get_class($this),
1497
                $this->getName(false)
1498
            );
1499
        }
1500
    }
1501
}
1502
1503
/* -------------------------------------------------
1504
 *
1505
 * This version of SapphireTest is for phpunit 5
1506
 * The phpunit 9 verison is at the top of this file
1507
 *
1508
 * PHPUnit_Extensions_GroupTestSuite is a class that only exists in phpunit 5
1509
 *
1510
 * -------------------------------------------------
1511
 */
1512
if (!class_exists(PHPUnit_Extensions_GroupTestSuite::class)) {
1513
    return;
1514
}
1515
1516
/**
1517
 * Test case class for the Sapphire framework.
1518
 * Sapphire unit testing is based on PHPUnit, but provides a number of hooks into our data model that make it easier
1519
 * to work with.
1520
 *
1521
 * This class should not be used anywhere outside of unit tests, as phpunit may not be installed
1522
 * in production sites.
1523
 */
1524
// Ignore multiple classes in same file
1525
// @codingStandardsIgnoreStart
1526
class SapphireTest extends PHPUnit_Framework_TestCase implements TestOnly
1527
{
1528
    use 
1529
    // @codingStandardsIgnoreEnd
1530
1531
    // Implementation of expect exception functions in phpunit 9
1532
1533
    // public function expectError(): void
1534
    // {
1535
    //     $this->expectException(PHPUnit_Framework_Error::class);
1536
    // }
1537
1538
    // public function expectErrorMessage(string $message): void
1539
    // {
1540
    //     $this->expectExceptionMessage($message);
1541
    // }
1542
1543
    // public function expectErrorMessageMatches(string $regularExpression): void
1544
    // {
1545
    //     $this->expectExceptionMessageMatches($regularExpression);
1546
    // }
1547
1548
    // public function expectWarning(): void
1549
    // {
1550
    //     $this->expectException(PHPUnit_Framework_Error_Warning::class);
1551
    // }
1552
1553
    // public function expectWarningMessage(string $message): void
1554
    // {
1555
    //     $this->expectExceptionMessage($message);
1556
    // }
1557
1558
    // public function expectWarningMessageMatches(string $regularExpression): void
1559
    // {
1560
    //     $this->expectExceptionMessageMatches($regularExpression);
1561
    // }
1562
1563
    // public function expectNotice(): void
1564
    // {
1565
    //     $this->expectException(PHPUnit_Framework_Error_Notice::class);
1566
    // }
1567
1568
    // public function expectNoticeMessage(string $message): void
1569
    // {
1570
    //     $this->expectExceptionMessage($message);
1571
    // }
1572
1573
    // public function expectNoticeMessageMatches(string $regularExpression): void
1574
    // {
1575
    //     $this->expectExceptionMessageMatches($regularExpression);
1576
    // }
1577
1578
    // public function expectDeprecation(): void
1579
    // {
1580
    //     $this->expectException(PHPUnit_Framework_Error_Deprecation::class);
1581
    // }
1582
1583
    // public function expectDeprecationMessage(string $message): void
1584
    // {
1585
    //     $this->expectExceptionMessage($message);
1586
    // }
1587
1588
    // public function expectDeprecationMessageMatches(string $regularExpression): void
1589
    // {
1590
    //     $this->expectExceptionMessageMatches($regularExpression);
1591
    // }
1592
1593
    // public function expectExceptionMessageMatches(string $regularExpression): void
1594
    // {
1595
    //     $this->expectExceptionMessageRegExp($regularExpression);
1596
    // }
1597
1598
    // public function assertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void
1599
    // {
1600
    //     $this->assertRegExp($pattern, $string, $message);
1601
    // }
1602
1603
    // public function assertDoesNotMatchRegularExpression(string $pattern, string $string, string $message = ''): void
1604
    // {
1605
    //     $this->assertNotRegExp($pattern, $string, $message);
1606
    // }
1607
1608
    // public function assertFileDoesNotExist(string $filename, string $message = ''): void
1609
    // {
1610
    //     $this->assertFileNotExists($filename, $message);
1611
    // }
1612
1613
    // =====
1614
1615
    /**
1616
     * Path to fixture data for this test run.
1617
     * If passed as an array, multiple fixture files will be loaded.
1618
     * Please note that you won't be able to refer with "=>" notation
1619
     * between the fixtures, they act independent of each other.
1620
     *
1621
     * @var string|array
1622
     */
1623
    protected static $fixture_file = null;
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_PROTECTED, expecting T_STRING or T_NAMESPACE or T_NS_SEPARATOR on line 1623 at column 4
Loading history...
1624
1625
    /**
1626
     * @deprecated 4.0..5.0 Use FixtureTestState instead
1627
     * @var FixtureFactory
1628
     */
1629
    protected $fixtureFactory;
1630
1631
    /**
1632
     * @var Boolean If set to TRUE, this will force a test database to be generated
1633
     * in {@link setUp()}. Note that this flag is overruled by the presence of a
1634
     * {@link $fixture_file}, which always forces a database build.
1635
     *
1636
     * @var bool
1637
     */
1638
    protected $usesDatabase = null;
1639
1640
    /**
1641
     * This test will cleanup its state via transactions.
1642
     * If set to false a full schema is forced between tests, but at a performance cost.
1643
     *
1644
     * @var bool
1645
     */
1646
    protected $usesTransactions = true;
1647
1648
    /**
1649
     * @var bool
1650
     */
1651
    protected static $is_running_test = false;
1652
1653
    /**
1654
     * By default, setUp() does not require default records. Pass
1655
     * class names in here, and the require/augment default records
1656
     * function will be called on them.
1657
     *
1658
     * @var array
1659
     */
1660
    protected $requireDefaultRecordsFrom = [];
1661
1662
    /**
1663
     * A list of extensions that can't be applied during the execution of this run.  If they are
1664
     * applied, they will be temporarily removed and a database migration called.
1665
     *
1666
     * The keys of the are the classes that the extensions can't be applied the extensions to, and
1667
     * the values are an array of illegal extensions on that class.
1668
     *
1669
     * Set a class to `*` to remove all extensions (unadvised)
1670
     *
1671
     * @var array
1672
     */
1673
    protected static $illegal_extensions = [];
1674
1675
    /**
1676
     * A list of extensions that must be applied during the execution of this run.  If they are
1677
     * not applied, they will be temporarily added and a database migration called.
1678
     *
1679
     * The keys of the are the classes to apply the extensions to, and the values are an array
1680
     * of required extensions on that class.
1681
     *
1682
     * Example:
1683
     * <code>
1684
     * array("MyTreeDataObject" => array("Versioned", "Hierarchy"))
1685
     * </code>
1686
     *
1687
     * @var array
1688
     */
1689
    protected static $required_extensions = [];
1690
1691
    /**
1692
     * By default, the test database won't contain any DataObjects that have the interface TestOnly.
1693
     * This variable lets you define additional TestOnly DataObjects to set up for this test.
1694
     * Set it to an array of DataObject subclass names.
1695
     *
1696
     * @var array
1697
     */
1698
    protected static $extra_dataobjects = [];
1699
1700
    /**
1701
     * List of class names of {@see Controller} objects to register routes for
1702
     * Controllers must implement Link() method
1703
     *
1704
     * @var array
1705
     */
1706
    protected static $extra_controllers = [];
1707
1708
    /**
1709
     * We need to disabling backing up of globals to avoid overriding
1710
     * the few globals SilverStripe relies on, like $lang for the i18n subsystem.
1711
     *
1712
     * @see http://sebastian-bergmann.de/archives/797-Global-Variables-and-PHPUnit.html
1713
     */
1714
    protected $backupGlobals = false;
1715
1716
    /**
1717
     * State management container for SapphireTest
1718
     *
1719
     * @var SapphireTestState
1720
     */
1721
    protected static $state = null;
1722
1723
    /**
1724
     * Temp database helper
1725
     *
1726
     * @var TempDatabase
1727
     */
1728
    protected static $tempDB = null;
1729
1730
    /**
1731
     * @return TempDatabase
1732
     */
1733
    public static function tempDB()
1734
    {
1735
        if (!class_exists(TempDatabase::class)) {
1736
            return null;
1737
        }
1738
1739
        if (!static::$tempDB) {
1740
            static::$tempDB = TempDatabase::create();
1741
        }
1742
        return static::$tempDB;
1743
    }
1744
1745
    /**
1746
     * Gets illegal extensions for this class
1747
     *
1748
     * @return array
1749
     */
1750
    public static function getIllegalExtensions()
1751
    {
1752
        return static::$illegal_extensions;
1753
    }
1754
1755
    /**
1756
     * Gets required extensions for this class
1757
     *
1758
     * @return array
1759
     */
1760
    public static function getRequiredExtensions()
1761
    {
1762
        return static::$required_extensions;
1763
    }
1764
1765
    /**
1766
     * Check if test bootstrapping has been performed. Must not be relied on
1767
     * outside of unit tests.
1768
     *
1769
     * @return bool
1770
     */
1771
    protected static function is_running_test()
1772
    {
1773
        return self::$is_running_test;
1774
    }
1775
1776
    /**
1777
     * Set test running state
1778
     *
1779
     * @param bool $bool
1780
     */
1781
    protected static function set_is_running_test($bool)
1782
    {
1783
        self::$is_running_test = $bool;
1784
    }
1785
1786
    /**
1787
     * @return String
1788
     */
1789
    public static function get_fixture_file()
1790
    {
1791
        return static::$fixture_file;
1792
    }
1793
1794
    /**
1795
     * @return bool
1796
     */
1797
    public function getUsesDatabase()
1798
    {
1799
        return $this->usesDatabase;
1800
    }
1801
1802
    /**
1803
     * @return bool
1804
     */
1805
    public function getUsesTransactions()
1806
    {
1807
        return $this->usesTransactions;
1808
    }
1809
1810
    /**
1811
     * @return array
1812
     */
1813
    public function getRequireDefaultRecordsFrom()
1814
    {
1815
        return $this->requireDefaultRecordsFrom;
1816
    }
1817
1818
    /**
1819
     * Setup  the test.
1820
     * Always sets up in order:
1821
     *  - Reset php state
1822
     *  - Nest
1823
     *  - Custom state helpers
1824
     *
1825
     * User code should call parent::setUp() before custom setup code
1826
     */
1827
    protected function setUp()
1828
    {
1829
        if (!defined('FRAMEWORK_PATH')) {
1830
            trigger_error(
1831
                'Missing constants, did you remember to include the test bootstrap in your phpunit.xml file?',
1832
                E_USER_WARNING
1833
            );
1834
        }
1835
1836
        // Call state helpers
1837
        static::$state->setUp($this);
1838
1839
        // We cannot run the tests on this abstract class.
1840
        if (static::class == __CLASS__) {
1841
            $this->markTestSkipped(sprintf('Skipping %s ', static::class));
1842
            return;
1843
        }
1844
1845
        // i18n needs to be set to the defaults or tests fail
1846
        if (class_exists(i18n::class)) {
1847
            i18n::set_locale(i18n::config()->uninherited('default_locale'));
1848
        }
1849
1850
        // Set default timezone consistently to avoid NZ-specific dependencies
1851
        date_default_timezone_set('UTC');
1852
1853
        if (class_exists(Member::class)) {
1854
            Member::set_password_validator(null);
1855
        }
1856
1857
        if (class_exists(Cookie::class)) {
1858
            Cookie::config()->update('report_errors', false);
1859
        }
1860
1861
        if (class_exists(RootURLController::class)) {
1862
            RootURLController::reset();
1863
        }
1864
1865
        if (class_exists(Security::class)) {
1866
            Security::clear_database_is_ready();
1867
        }
1868
1869
        // Set up test routes
1870
        $this->setUpRoutes();
1871
1872
        $fixtureFiles = $this->getFixturePaths();
1873
1874
        if ($this->shouldSetupDatabaseForCurrentTest($fixtureFiles)) {
1875
            // Assign fixture factory to deprecated prop in case old tests use it over the getter
1876
            /** @var FixtureTestState $fixtureState */
1877
            $fixtureState = static::$state->getStateByName('fixtures');
1878
            $this->fixtureFactory = $fixtureState->getFixtureFactory(static::class);
1879
1880
            $this->logInWithPermission('ADMIN');
1881
        }
1882
1883
        // turn off template debugging
1884
        if (class_exists(SSViewer::class)) {
1885
            SSViewer::config()->update('source_file_comments', false);
1886
        }
1887
1888
        // Set up the test mailer
1889
        if (class_exists(TestMailer::class)) {
1890
            Injector::inst()->registerService(new TestMailer(), Mailer::class);
1891
        }
1892
1893
        if (class_exists(Email::class)) {
1894
            Email::config()->remove('send_all_emails_to');
1895
            Email::config()->remove('send_all_emails_from');
1896
            Email::config()->remove('cc_all_emails_to');
1897
            Email::config()->remove('bcc_all_emails_to');
1898
        }
1899
    }
1900
1901
1902
    /**
1903
     * Helper method to determine if the current test should enable a test database
1904
     *
1905
     * @param $fixtureFiles
1906
     * @return bool
1907
     */
1908
    protected function shouldSetupDatabaseForCurrentTest($fixtureFiles)
1909
    {
1910
        $databaseEnabledByDefault = $fixtureFiles || $this->usesDatabase;
1911
1912
        return ($databaseEnabledByDefault && !$this->currentTestDisablesDatabase())
1913
            || $this->currentTestEnablesDatabase();
1914
    }
1915
1916
    /**
1917
     * Helper method to check, if the current test uses the database.
1918
     * This can be switched on with the annotation "@useDatabase"
1919
     *
1920
     * @return bool
1921
     */
1922
    protected function currentTestEnablesDatabase()
1923
    {
1924
        $annotations = $this->getAnnotations();
1925
1926
        return array_key_exists('useDatabase', $annotations['method'])
1927
            && $annotations['method']['useDatabase'][0] !== 'false';
1928
    }
1929
1930
    /**
1931
     * Helper method to check, if the current test uses the database.
1932
     * This can be switched on with the annotation "@useDatabase false"
1933
     *
1934
     * @return bool
1935
     */
1936
    protected function currentTestDisablesDatabase()
1937
    {
1938
        $annotations = $this->getAnnotations();
1939
1940
        return array_key_exists('useDatabase', $annotations['method'])
1941
            && $annotations['method']['useDatabase'][0] === 'false';
1942
    }
1943
1944
    /**
1945
     * Called once per test case ({@link SapphireTest} subclass).
1946
     * This is different to {@link setUp()}, which gets called once
1947
     * per method. Useful to initialize expensive operations which
1948
     * don't change state for any called method inside the test,
1949
     * e.g. dynamically adding an extension. See {@link teardownAfterClass()}
1950
     * for tearing down the state again.
1951
     *
1952
     * Always sets up in order:
1953
     *  - Reset php state
1954
     *  - Nest
1955
     *  - Custom state helpers
1956
     *
1957
     * User code should call parent::setUpBeforeClass() before custom setup code
1958
     *
1959
     * @throws Exception
1960
     */
1961
    public static function setUpBeforeClass()
1962
    {
1963
        // Start tests
1964
        static::start();
1965
1966
        if (!static::$state) {
1967
            throw new Exception('SapphireTest failed to bootstrap!');
1968
        }
1969
1970
        // Call state helpers
1971
        static::$state->setUpOnce(static::class);
1972
1973
        // Build DB if we have objects
1974
        if (class_exists(DataObject::class) && static::getExtraDataObjects()) {
1975
            DataObject::reset();
1976
            static::resetDBSchema(true, true);
1977
        }
1978
    }
1979
1980
    /**
1981
     * tearDown method that's called once per test class rather once per test method.
1982
     *
1983
     * Always sets up in order:
1984
     *  - Custom state helpers
1985
     *  - Unnest
1986
     *  - Reset php state
1987
     *
1988
     * User code should call parent::tearDownAfterClass() after custom tear down code
1989
     */
1990
    public static function tearDownAfterClass()
1991
    {
1992
        // Call state helpers
1993
        static::$state->tearDownOnce(static::class);
1994
1995
        // Reset DB schema
1996
        static::resetDBSchema();
1997
    }
1998
1999
    /**
2000
     * @return FixtureFactory|false
2001
     * @deprecated 4.0.0:5.0.0
2002
     */
2003
    public function getFixtureFactory()
2004
    {
2005
        Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
2006
        /** @var FixtureTestState $state */
2007
        $state = static::$state->getStateByName('fixtures');
2008
        return $state->getFixtureFactory(static::class);
2009
    }
2010
2011
    /**
2012
     * Sets a new fixture factory
2013
     * @param FixtureFactory $factory
2014
     * @return $this
2015
     * @deprecated 4.0.0:5.0.0
2016
     */
2017
    public function setFixtureFactory(FixtureFactory $factory)
2018
    {
2019
        Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
2020
        /** @var FixtureTestState $state */
2021
        $state = static::$state->getStateByName('fixtures');
2022
        $state->setFixtureFactory($factory, static::class);
2023
        $this->fixtureFactory = $factory;
2024
        return $this;
2025
    }
2026
2027
    /**
2028
     * Get the ID of an object from the fixture.
2029
     *
2030
     * @param string $className The data class or table name, as specified in your fixture file.  Parent classes won't work
2031
     * @param string $identifier The identifier string, as provided in your fixture file
2032
     * @return int
2033
     */
2034
    protected function idFromFixture($className, $identifier)
2035
    {
2036
        /** @var FixtureTestState $state */
2037
        $state = static::$state->getStateByName('fixtures');
2038
        $id = $state->getFixtureFactory(static::class)->getId($className, $identifier);
2039
2040
        if (!$id) {
2041
            throw new \InvalidArgumentException(sprintf(
2042
                "Couldn't find object '%s' (class: %s)",
2043
                $identifier,
2044
                $className
2045
            ));
2046
        }
2047
2048
        return $id;
2049
    }
2050
2051
    /**
2052
     * Return all of the IDs in the fixture of a particular class name.
2053
     * Will collate all IDs form all fixtures if multiple fixtures are provided.
2054
     *
2055
     * @param string $className The data class or table name, as specified in your fixture file
2056
     * @return array A map of fixture-identifier => object-id
2057
     */
2058
    protected function allFixtureIDs($className)
2059
    {
2060
        /** @var FixtureTestState $state */
2061
        $state = static::$state->getStateByName('fixtures');
2062
        return $state->getFixtureFactory(static::class)->getIds($className);
2063
    }
2064
2065
    /**
2066
     * Get an object from the fixture.
2067
     *
2068
     * @param string $className The data class or table name, as specified in your fixture file. Parent classes won't work
2069
     * @param string $identifier The identifier string, as provided in your fixture file
2070
     *
2071
     * @return DataObject
2072
     */
2073
    protected function objFromFixture($className, $identifier)
2074
    {
2075
        /** @var FixtureTestState $state */
2076
        $state = static::$state->getStateByName('fixtures');
2077
        $obj = $state->getFixtureFactory(static::class)->get($className, $identifier);
2078
2079
        if (!$obj) {
2080
            throw new \InvalidArgumentException(sprintf(
2081
                "Couldn't find object '%s' (class: %s)",
2082
                $identifier,
2083
                $className
2084
            ));
2085
        }
2086
2087
        return $obj;
2088
    }
2089
2090
    /**
2091
     * Load a YAML fixture file into the database.
2092
     * Once loaded, you can use idFromFixture() and objFromFixture() to get items from the fixture.
2093
     * Doesn't clear existing fixtures.
2094
     * @param string $fixtureFile The location of the .yml fixture file, relative to the site base dir
2095
     * @deprecated 4.0.0:5.0.0
2096
     *
2097
     */
2098
    public function loadFixture($fixtureFile)
2099
    {
2100
        Deprecation::notice('5.0', __FUNCTION__ . ' is deprecated, use ' . FixtureTestState::class . ' instead');
2101
        $fixture = Injector::inst()->create(YamlFixture::class, $fixtureFile);
2102
        $fixture->writeInto($this->getFixtureFactory());
2103
    }
2104
2105
    /**
2106
     * Clear all fixtures which were previously loaded through
2107
     * {@link loadFixture()}
2108
     */
2109
    public function clearFixtures()
2110
    {
2111
        /** @var FixtureTestState $state */
2112
        $state = static::$state->getStateByName('fixtures');
2113
        $state->getFixtureFactory(static::class)->clear();
2114
    }
2115
2116
    /**
2117
     * Useful for writing unit tests without hardcoding folder structures.
2118
     *
2119
     * @return string Absolute path to current class.
2120
     */
2121
    protected function getCurrentAbsolutePath()
2122
    {
2123
        $filename = ClassLoader::inst()->getItemPath(static::class);
2124
        if (!$filename) {
2125
            throw new LogicException('getItemPath returned null for ' . static::class
2126
                . '. Try adding flush=1 to the test run.');
2127
        }
2128
        return dirname($filename);
2129
    }
2130
2131
    /**
2132
     * @return string File path relative to webroot
2133
     */
2134
    protected function getCurrentRelativePath()
2135
    {
2136
        $base = Director::baseFolder();
2137
        $path = $this->getCurrentAbsolutePath();
2138
        if (substr($path, 0, strlen($base)) == $base) {
2139
            $path = preg_replace('/^\/*/', '', substr($path, strlen($base)));
2140
        }
2141
        return $path;
2142
    }
2143
2144
    /**
2145
     * Setup  the test.
2146
     * Always sets up in order:
2147
     *  - Custom state helpers
2148
     *  - Unnest
2149
     *  - Reset php state
2150
     *
2151
     * User code should call parent::tearDown() after custom tear down code
2152
     */
2153
    protected function tearDown()
2154
    {
2155
        // Reset mocked datetime
2156
        if (class_exists(DBDatetime::class)) {
2157
            DBDatetime::clear_mock_now();
2158
        }
2159
2160
        // Stop the redirection that might have been requested in the test.
2161
        // Note: Ideally a clean Controller should be created for each test.
2162
        // Now all tests executed in a batch share the same controller.
2163
        if (class_exists(Controller::class)) {
2164
            $controller = Controller::has_curr() ? Controller::curr() : null;
2165
            if ($controller && ($response = $controller->getResponse()) && $response->getHeader('Location')) {
2166
                $response->setStatusCode(200);
2167
                $response->removeHeader('Location');
2168
            }
2169
        }
2170
2171
        // Call state helpers
2172
        static::$state->tearDown($this);
2173
    }
2174
2175
    public static function assertContains(
2176
        $needle,
2177
        $haystack,
2178
        $message = '',
2179
        $ignoreCase = false,
2180
        $checkForObjectIdentity = true,
2181
        $checkForNonObjectIdentity = false
2182
    ) {
2183
        if ($haystack instanceof DBField) {
2184
            $haystack = (string)$haystack;
2185
        }
2186
        parent::assertContains($needle, $haystack, $message, $ignoreCase, $checkForObjectIdentity, $checkForNonObjectIdentity);
2187
    }
2188
2189
    public static function assertNotContains(
2190
        $needle,
2191
        $haystack,
2192
        $message = '',
2193
        $ignoreCase = false,
2194
        $checkForObjectIdentity = true,
2195
        $checkForNonObjectIdentity = false
2196
    ) {
2197
        if ($haystack instanceof DBField) {
2198
            $haystack = (string)$haystack;
2199
        }
2200
        parent::assertNotContains($needle, $haystack, $message, $ignoreCase, $checkForObjectIdentity, $checkForNonObjectIdentity);
2201
    }
2202
2203
    /**
2204
     * Clear the log of emails sent
2205
     *
2206
     * @return bool True if emails cleared
2207
     */
2208
    public function clearEmails()
2209
    {
2210
        /** @var Mailer $mailer */
2211
        $mailer = Injector::inst()->get(Mailer::class);
2212
        if ($mailer instanceof TestMailer) {
2213
            $mailer->clearEmails();
2214
            return true;
2215
        }
2216
        return false;
2217
    }
2218
2219
    /**
2220
     * Search for an email that was sent.
2221
     * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
2222
     * @param string $to
2223
     * @param string $from
2224
     * @param string $subject
2225
     * @param string $content
2226
     * @return array|null Contains keys: 'Type', 'To', 'From', 'Subject', 'Content', 'PlainContent', 'AttachedFiles',
2227
     *               'HtmlContent'
2228
     */
2229
    public static function findEmail($to, $from = null, $subject = null, $content = null)
2230
    {
2231
        /** @var Mailer $mailer */
2232
        $mailer = Injector::inst()->get(Mailer::class);
2233
        if ($mailer instanceof TestMailer) {
2234
            return $mailer->findEmail($to, $from, $subject, $content);
2235
        }
2236
        return null;
2237
    }
2238
2239
    /**
2240
     * Assert that the matching email was sent since the last call to clearEmails()
2241
     * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
2242
     *
2243
     * @param string $to
2244
     * @param string $from
2245
     * @param string $subject
2246
     * @param string $content
2247
     */
2248
    public static function assertEmailSent($to, $from = null, $subject = null, $content = null)
2249
    {
2250
        $found = (bool)static::findEmail($to, $from, $subject, $content);
2251
2252
        $infoParts = '';
2253
        $withParts = [];
2254
        if ($to) {
2255
            $infoParts .= " to '$to'";
2256
        }
2257
        if ($from) {
2258
            $infoParts .= " from '$from'";
2259
        }
2260
        if ($subject) {
2261
            $withParts[] = "subject '$subject'";
2262
        }
2263
        if ($content) {
2264
            $withParts[] = "content '$content'";
2265
        }
2266
        if ($withParts) {
2267
            $infoParts .= ' with ' . implode(' and ', $withParts);
2268
        }
2269
2270
        static::assertTrue(
2271
            $found,
2272
            "Failed asserting that an email was sent$infoParts."
2273
        );
2274
    }
2275
2276
2277
    /**
2278
     * Assert that the given {@link SS_List} includes DataObjects matching the given key-value
2279
     * pairs.  Each match must correspond to 1 distinct record.
2280
     *
2281
     * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
2282
     * either pass a single pattern or an array of patterns.
2283
     * @param SS_List $list The {@link SS_List} to test.
2284
     * @param string $message
2285
     *
2286
     * Examples
2287
     * --------
2288
     * Check that $members includes an entry with Email = [email protected]:
2289
     *      $this->assertListContains(['Email' => '[email protected]'], $members);
2290
     *
2291
     * Check that $members includes entries with Email = [email protected] and with
2292
     * Email = [email protected]:
2293
     *      $this->assertListContains([
2294
     *         ['Email' => '[email protected]'],
2295
     *         ['Email' => '[email protected]'],
2296
     *      ], $members);
2297
     */
2298
    public static function assertListContains($matches, SS_List $list, $message = '')
2299
    {
2300
        if (!is_array($matches)) {
2301
            throw PHPUnit_Util_InvalidArgumentHelper::factory(
2302
                1,
2303
                'array'
2304
            );
2305
        }
2306
2307
        static::assertThat(
2308
            $list,
2309
            new SSListContains(
2310
                $matches
2311
            ),
2312
            $message
2313
        );
2314
    }
2315
2316
    /**
2317
     * @param $matches
2318
     * @param $dataObjectSet
2319
     * @deprecated 4.0.0:5.0.0 Use assertListContains() instead
2320
     *
2321
     */
2322
    public function assertDOSContains($matches, $dataObjectSet)
2323
    {
2324
        Deprecation::notice('5.0', 'Use assertListContains() instead');
2325
        return static::assertListContains($matches, $dataObjectSet);
2326
    }
2327
2328
    /**
2329
     * Asserts that no items in a given list appear in the given dataobject list
2330
     *
2331
     * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
2332
     * either pass a single pattern or an array of patterns.
2333
     * @param SS_List $list The {@link SS_List} to test.
2334
     * @param string $message
2335
     *
2336
     * Examples
2337
     * --------
2338
     * Check that $members doesn't have an entry with Email = [email protected]:
2339
     *      $this->assertListNotContains(['Email' => '[email protected]'], $members);
2340
     *
2341
     * Check that $members doesn't have entries with Email = [email protected] and with
2342
     * Email = [email protected]:
2343
     *      $this->assertListNotContains([
2344
     *          ['Email' => '[email protected]'],
2345
     *          ['Email' => '[email protected]'],
2346
     *      ], $members);
2347
     */
2348
    public static function assertListNotContains($matches, SS_List $list, $message = '')
2349
    {
2350
        if (!is_array($matches)) {
2351
            throw PHPUnit_Util_InvalidArgumentHelper::factory(
2352
                1,
2353
                'array'
2354
            );
2355
        }
2356
2357
        $constraint = new PHPUnit_Framework_Constraint_Not(
2358
            new SSListContains(
2359
                $matches
2360
            )
2361
        );
2362
2363
        static::assertThat(
2364
            $list,
2365
            $constraint,
2366
            $message
2367
        );
2368
    }
2369
2370
    /**
2371
     * @param $matches
2372
     * @param $dataObjectSet
2373
     * @deprecated 4.0.0:5.0.0 Use assertListNotContains() instead
2374
     *
2375
     */
2376
    public static function assertNotDOSContains($matches, $dataObjectSet)
2377
    {
2378
        Deprecation::notice('5.0', 'Use assertListNotContains() instead');
2379
        return static::assertListNotContains($matches, $dataObjectSet);
2380
    }
2381
2382
    /**
2383
     * Assert that the given {@link SS_List} includes only DataObjects matching the given
2384
     * key-value pairs.  Each match must correspond to 1 distinct record.
2385
     *
2386
     * Example
2387
     * --------
2388
     * Check that *only* the entries Sam Minnee and Ingo Schommer exist in $members.  Order doesn't
2389
     * matter:
2390
     *     $this->assertListEquals([
2391
     *        ['FirstName' =>'Sam', 'Surname' => 'Minnee'],
2392
     *        ['FirstName' => 'Ingo', 'Surname' => 'Schommer'],
2393
     *      ], $members);
2394
     *
2395
     * @param mixed $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
2396
     * either pass a single pattern or an array of patterns.
2397
     * @param mixed $list The {@link SS_List} to test.
2398
     * @param string $message
2399
     */
2400
    public static function assertListEquals($matches, SS_List $list, $message = '')
2401
    {
2402
        if (!is_array($matches)) {
2403
            throw PHPUnit_Util_InvalidArgumentHelper::factory(
2404
                1,
2405
                'array'
2406
            );
2407
        }
2408
2409
        static::assertThat(
2410
            $list,
2411
            new SSListContainsOnly(
2412
                $matches
2413
            ),
2414
            $message
2415
        );
2416
    }
2417
2418
    /**
2419
     * @param $matches
2420
     * @param SS_List $dataObjectSet
2421
     * @deprecated 4.0.0:5.0.0 Use assertListEquals() instead
2422
     *
2423
     */
2424
    public function assertDOSEquals($matches, $dataObjectSet)
2425
    {
2426
        Deprecation::notice('5.0', 'Use assertListEquals() instead');
2427
        return static::assertListEquals($matches, $dataObjectSet);
2428
    }
2429
2430
2431
    /**
2432
     * Assert that the every record in the given {@link SS_List} matches the given key-value
2433
     * pairs.
2434
     *
2435
     * Example
2436
     * --------
2437
     * Check that every entry in $members has a Status of 'Active':
2438
     *     $this->assertListAllMatch(['Status' => 'Active'], $members);
2439
     *
2440
     * @param mixed $match The pattern to match.  The pattern is a map of key-value pairs.
2441
     * @param mixed $list The {@link SS_List} to test.
2442
     * @param string $message
2443
     */
2444
    public static function assertListAllMatch($match, SS_List $list, $message = '')
2445
    {
2446
        if (!is_array($match)) {
2447
            throw PHPUnit_Util_InvalidArgumentHelper::factory(
2448
                1,
2449
                'array'
2450
            );
2451
        }
2452
2453
        static::assertThat(
2454
            $list,
2455
            new SSListContainsOnlyMatchingItems(
2456
                $match
2457
            ),
2458
            $message
2459
        );
2460
    }
2461
2462
    /**
2463
     * @param $match
2464
     * @param SS_List $dataObjectSet
2465
     * @deprecated 4.0.0:5.0.0 Use assertListAllMatch() instead
2466
     *
2467
     */
2468
    public function assertDOSAllMatch($match, SS_List $dataObjectSet)
2469
    {
2470
        Deprecation::notice('5.0', 'Use assertListAllMatch() instead');
2471
        return static::assertListAllMatch($match, $dataObjectSet);
2472
    }
2473
2474
    /**
2475
     * Removes sequences of repeated whitespace characters from SQL queries
2476
     * making them suitable for string comparison
2477
     *
2478
     * @param string $sql
2479
     * @return string The cleaned and normalised SQL string
2480
     */
2481
    protected static function normaliseSQL($sql)
2482
    {
2483
        return trim(preg_replace('/\s+/m', ' ', $sql));
2484
    }
2485
2486
    /**
2487
     * Asserts that two SQL queries are equivalent
2488
     *
2489
     * @param string $expectedSQL
2490
     * @param string $actualSQL
2491
     * @param string $message
2492
     * @param float|int $delta
2493
     * @param integer $maxDepth
2494
     * @param boolean $canonicalize
2495
     * @param boolean $ignoreCase
2496
     */
2497
    public static function assertSQLEquals(
2498
        $expectedSQL,
2499
        $actualSQL,
2500
        $message = '',
2501
        $delta = 0,
2502
        $maxDepth = 10,
2503
        $canonicalize = false,
2504
        $ignoreCase = false
2505
    ) {
2506
        // Normalise SQL queries to remove patterns of repeating whitespace
2507
        $expectedSQL = static::normaliseSQL($expectedSQL);
2508
        $actualSQL = static::normaliseSQL($actualSQL);
2509
2510
        static::assertEquals($expectedSQL, $actualSQL, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
2511
    }
2512
2513
    /**
2514
     * Asserts that a SQL query contains a SQL fragment
2515
     *
2516
     * @param string $needleSQL
2517
     * @param string $haystackSQL
2518
     * @param string $message
2519
     * @param boolean $ignoreCase
2520
     * @param boolean $checkForObjectIdentity
2521
     */
2522
    public static function assertSQLContains(
2523
        $needleSQL,
2524
        $haystackSQL,
2525
        $message = '',
2526
        $ignoreCase = false,
2527
        $checkForObjectIdentity = true
2528
    ) {
2529
        $needleSQL = static::normaliseSQL($needleSQL);
2530
        $haystackSQL = static::normaliseSQL($haystackSQL);
2531
2532
        static::assertContains($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
2533
    }
2534
2535
    /**
2536
     * Asserts that a SQL query contains a SQL fragment
2537
     *
2538
     * @param string $needleSQL
2539
     * @param string $haystackSQL
2540
     * @param string $message
2541
     * @param boolean $ignoreCase
2542
     * @param boolean $checkForObjectIdentity
2543
     */
2544
    public static function assertSQLNotContains(
2545
        $needleSQL,
2546
        $haystackSQL,
2547
        $message = '',
2548
        $ignoreCase = false,
2549
        $checkForObjectIdentity = true
2550
    ) {
2551
        $needleSQL = static::normaliseSQL($needleSQL);
2552
        $haystackSQL = static::normaliseSQL($haystackSQL);
2553
2554
        static::assertNotContains($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
2555
    }
2556
2557
    /**
2558
     * Start test environment
2559
     */
2560
    public static function start()
2561
    {
2562
        if (static::is_running_test()) {
2563
            return;
2564
        }
2565
2566
        // Health check
2567
        if (InjectorLoader::inst()->countManifests()) {
2568
            throw new LogicException('SapphireTest::start() cannot be called within another application');
2569
        }
2570
        static::set_is_running_test(true);
2571
2572
        // Test application
2573
        $kernel = new TestKernel(BASE_PATH);
2574
2575
        if (class_exists(HTTPApplication::class)) {
2576
            // Mock request
2577
            $_SERVER['argv'] = ['vendor/bin/phpunit', '/'];
2578
            $request = CLIRequestBuilder::createFromEnvironment();
2579
2580
            $app = new HTTPApplication($kernel);
2581
            $flush = array_key_exists('flush', $request->getVars());
2582
2583
            // Custom application
2584
            $res = $app->execute($request, function (HTTPRequest $request) {
2585
                // Start session and execute
2586
                $request->getSession()->init($request);
2587
2588
                // Invalidate classname spec since the test manifest will now pull out new subclasses for each internal class
2589
                // (e.g. Member will now have various subclasses of DataObjects that implement TestOnly)
2590
                DataObject::reset();
2591
2592
                // Set dummy controller;
2593
                $controller = Controller::create();
2594
                $controller->setRequest($request);
2595
                $controller->pushCurrent();
2596
                $controller->doInit();
2597
            }, $flush);
2598
2599
            if ($res && $res->isError()) {
2600
                throw new LogicException($res->getBody());
2601
            }
2602
        } else {
2603
            // Allow flush from the command line in the absence of HTTPApplication's special sauce
2604
            $flush = false;
2605
            foreach ($_SERVER['argv'] as $arg) {
2606
                if (preg_match('/^(--)?flush(=1)?$/', $arg)) {
2607
                    $flush = true;
2608
                }
2609
            }
2610
            $kernel->boot($flush);
2611
        }
2612
2613
        // Register state
2614
        static::$state = SapphireTestState::singleton();
2615
        // Register temp DB holder
2616
        static::tempDB();
2617
    }
2618
2619
    /**
2620
     * Reset the testing database's schema, but only if it is active
2621
     * @param bool $includeExtraDataObjects If true, the extraDataObjects tables will also be included
2622
     * @param bool $forceCreate Force DB to be created if it doesn't exist
2623
     */
2624
    public static function resetDBSchema($includeExtraDataObjects = false, $forceCreate = false)
2625
    {
2626
        if (!static::$tempDB) {
2627
            return;
2628
        }
2629
2630
        // Check if DB is active before reset
2631
        if (!static::$tempDB->isUsed()) {
2632
            if (!$forceCreate) {
2633
                return;
2634
            }
2635
            static::$tempDB->build();
2636
        }
2637
        $extraDataObjects = $includeExtraDataObjects ? static::getExtraDataObjects() : [];
2638
        static::$tempDB->resetDBSchema((array)$extraDataObjects);
2639
    }
2640
2641
    /**
2642
     * A wrapper for automatically performing callbacks as a user with a specific permission
2643
     *
2644
     * @param string|array $permCode
2645
     * @param callable $callback
2646
     * @return mixed
2647
     */
2648
    public function actWithPermission($permCode, $callback)
2649
    {
2650
        return Member::actAs($this->createMemberWithPermission($permCode), $callback);
2651
    }
2652
2653
    /**
2654
     * Create Member and Group objects on demand with specific permission code
2655
     *
2656
     * @param string|array $permCode
2657
     * @return Member
2658
     */
2659
    protected function createMemberWithPermission($permCode)
2660
    {
2661
        if (is_array($permCode)) {
2662
            $permArray = $permCode;
2663
            $permCode = implode('.', $permCode);
2664
        } else {
2665
            $permArray = [$permCode];
2666
        }
2667
2668
        // Check cached member
2669
        if (isset($this->cache_generatedMembers[$permCode])) {
2670
            $member = $this->cache_generatedMembers[$permCode];
2671
        } else {
2672
            // Generate group with these permissions
2673
            $group = Group::create();
2674
            $group->Title = "$permCode group";
2675
            $group->write();
2676
2677
            // Create each individual permission
2678
            foreach ($permArray as $permArrayItem) {
2679
                $permission = Permission::create();
2680
                $permission->Code = $permArrayItem;
2681
                $permission->write();
2682
                $group->Permissions()->add($permission);
2683
            }
2684
2685
            $member = Member::get()->filter([
2686
                'Email' => "[email protected]",
2687
            ])->first();
2688
            if (!$member) {
2689
                $member = Member::create();
2690
            }
2691
2692
            $member->FirstName = $permCode;
2693
            $member->Surname = 'User';
2694
            $member->Email = "[email protected]";
2695
            $member->write();
2696
            $group->Members()->add($member);
2697
2698
            $this->cache_generatedMembers[$permCode] = $member;
2699
        }
2700
        return $member;
2701
    }
2702
2703
    /**
2704
     * Create a member and group with the given permission code, and log in with it.
2705
     * Returns the member ID.
2706
     *
2707
     * @param string|array $permCode Either a permission, or list of permissions
2708
     * @return int Member ID
2709
     */
2710
    public function logInWithPermission($permCode = 'ADMIN')
2711
    {
2712
        $member = $this->createMemberWithPermission($permCode);
2713
        $this->logInAs($member);
2714
        return $member->ID;
2715
    }
2716
2717
    /**
2718
     * Log in as the given member
2719
     *
2720
     * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in
2721
     */
2722
    public function logInAs($member)
2723
    {
2724
        if (is_numeric($member)) {
2725
            $member = DataObject::get_by_id(Member::class, $member);
2726
        } elseif (!is_object($member)) {
2727
            $member = $this->objFromFixture(Member::class, $member);
2728
        }
2729
        Injector::inst()->get(IdentityStore::class)->logIn($member);
2730
    }
2731
2732
    /**
2733
     * Log out the current user
2734
     */
2735
    public function logOut()
2736
    {
2737
        /** @var IdentityStore $store */
2738
        $store = Injector::inst()->get(IdentityStore::class);
2739
        $store->logOut();
2740
    }
2741
2742
    /**
2743
     * Cache for logInWithPermission()
2744
     */
2745
    protected $cache_generatedMembers = [];
2746
2747
    /**
2748
     * Test against a theme.
2749
     *
2750
     * @param string $themeBaseDir themes directory
2751
     * @param string $theme Theme name
2752
     * @param callable $callback
2753
     * @throws Exception
2754
     */
2755
    protected function useTestTheme($themeBaseDir, $theme, $callback)
2756
    {
2757
        Config::nest();
2758
        if (strpos($themeBaseDir, BASE_PATH) === 0) {
2759
            $themeBaseDir = substr($themeBaseDir, strlen(BASE_PATH));
2760
        }
2761
        SSViewer::config()->update('theme_enabled', true);
2762
        SSViewer::set_themes([$themeBaseDir . '/themes/' . $theme, '$default']);
2763
2764
        try {
2765
            $callback();
2766
        } finally {
2767
            Config::unnest();
2768
        }
2769
    }
2770
2771
    /**
2772
     * Get fixture paths for this test
2773
     *
2774
     * @return array List of paths
2775
     */
2776
    protected function getFixturePaths()
2777
    {
2778
        $fixtureFile = static::get_fixture_file();
2779
        if (empty($fixtureFile)) {
2780
            return [];
2781
        }
2782
2783
        $fixtureFiles = is_array($fixtureFile) ? $fixtureFile : [$fixtureFile];
2784
2785
        return array_map(function ($fixtureFilePath) {
2786
            return $this->resolveFixturePath($fixtureFilePath);
2787
        }, $fixtureFiles);
2788
    }
2789
2790
    /**
2791
     * Return all extra objects to scaffold for this test
2792
     * @return array
2793
     */
2794
    public static function getExtraDataObjects()
2795
    {
2796
        return static::$extra_dataobjects;
2797
    }
2798
2799
    /**
2800
     * Get additional controller classes to register routes for
2801
     *
2802
     * @return array
2803
     */
2804
    public static function getExtraControllers()
2805
    {
2806
        return static::$extra_controllers;
2807
    }
2808
2809
    /**
2810
     * Map a fixture path to a physical file
2811
     *
2812
     * @param string $fixtureFilePath
2813
     * @return string
2814
     */
2815
    protected function resolveFixturePath($fixtureFilePath)
2816
    {
2817
        // support loading via composer name path.
2818
        if (strpos($fixtureFilePath, ':') !== false) {
2819
            return ModuleResourceLoader::singleton()->resolvePath($fixtureFilePath);
2820
        }
2821
2822
        // Support fixture paths relative to the test class, rather than relative to webroot
2823
        // String checking is faster than file_exists() calls.
2824
        $resolvedPath = realpath($this->getCurrentAbsolutePath() . '/' . $fixtureFilePath);
2825
        if ($resolvedPath) {
2826
            return $resolvedPath;
2827
        }
2828
2829
        // Check if file exists relative to base dir
2830
        $resolvedPath = realpath(Director::baseFolder() . '/' . $fixtureFilePath);
2831
        if ($resolvedPath) {
2832
            return $resolvedPath;
2833
        }
2834
2835
        return $fixtureFilePath;
2836
    }
2837
2838
    protected function setUpRoutes()
2839
    {
2840
        if (!class_exists(Director::class)) {
2841
            return;
2842
        }
2843
2844
        // Get overridden routes
2845
        $rules = $this->getExtraRoutes();
2846
2847
        // Add all other routes
2848
        foreach (Director::config()->uninherited('rules') as $route => $rule) {
2849
            if (!isset($rules[$route])) {
2850
                $rules[$route] = $rule;
2851
            }
2852
        }
2853
2854
        // Add default catch-all rule
2855
        $rules['$Controller//$Action/$ID/$OtherID'] = '*';
2856
2857
        // Add controller-name auto-routing
2858
        Director::config()->set('rules', $rules);
2859
    }
2860
2861
    /**
2862
     * Get extra routes to merge into Director.rules
2863
     *
2864
     * @return array
2865
     */
2866
    protected function getExtraRoutes()
2867
    {
2868
        $rules = [];
2869
        foreach ($this->getExtraControllers() as $class) {
2870
            $controllerInst = Controller::singleton($class);
2871
            $link = Director::makeRelative($controllerInst->Link());
2872
            $route = rtrim($link, '/') . '//$Action/$ID/$OtherID';
2873
            $rules[$route] = $class;
2874
        }
2875
        return $rules;
2876
    }
2877
}
2878