Completed
Push — master ( c17796...052b15 )
by Damian
01:29
created

SapphireTest::getRequiredExtensions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
0 ignored issues
show
Coding Style Compatibility introduced by
For compatibility and reusability of your code, PSR1 recommends that a file should introduce either new symbols (like classes, functions, etc.) or have side-effects (like outputting something, or including other files), but not both at the same time. The first symbol is defined on line 53 and the first side effect is on line 42.

The PSR-1: Basic Coding Standard recommends that a file should either introduce new symbols, that is classes, functions, constants or similar, or have side effects. Side effects are anything that executes logic, like for example printing output, changing ini settings or writing to a file.

The idea behind this recommendation is that merely auto-loading a class should not change the state of an application. It also promotes a cleaner style of programming and makes your code less prone to errors, because the logic is not spread out all over the place.

To learn more about the PSR-1, please see the PHP-FIG site on the PSR-1.

Loading history...
2
3
namespace SilverStripe\Dev;
4
5
use Exception;
6
use LogicException;
7
use PHPUnit\Framework\Constraint\LogicalNot;
8
use PHPUnit\Framework\TestCase;
9
use PHPUnit\Util\InvalidArgumentHelper;
10
use SilverStripe\CMS\Controllers\RootURLController;
0 ignored issues
show
Bug introduced by
The type SilverStripe\CMS\Controllers\RootURLController was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
11
use SilverStripe\Control\CLIRequestBuilder;
12
use SilverStripe\Control\Controller;
13
use SilverStripe\Control\Cookie;
14
use SilverStripe\Control\Director;
15
use SilverStripe\Control\Email\Email;
16
use SilverStripe\Control\Email\Mailer;
17
use SilverStripe\Control\HTTPApplication;
18
use SilverStripe\Control\HTTPRequest;
19
use SilverStripe\Core\Config\Config;
20
use SilverStripe\Core\Injector\Injector;
21
use SilverStripe\Core\Injector\InjectorLoader;
22
use SilverStripe\Core\Manifest\ClassLoader;
23
use SilverStripe\Dev\Constraint\SSListContains;
24
use SilverStripe\Dev\Constraint\SSListContainsOnly;
25
use SilverStripe\Dev\Constraint\SSListContainsOnlyMatchingItems;
26
use SilverStripe\Dev\State\SapphireTestState;
27
use SilverStripe\Dev\State\TestState;
28
use SilverStripe\i18n\i18n;
29
use SilverStripe\ORM\Connect\TempDatabase;
30
use SilverStripe\ORM\DataObject;
31
use SilverStripe\ORM\FieldType\DBDatetime;
32
use SilverStripe\ORM\FieldType\DBField;
33
use SilverStripe\ORM\SS_List;
34
use SilverStripe\Security\Group;
35
use SilverStripe\Security\IdentityStore;
36
use SilverStripe\Security\Member;
37
use SilverStripe\Security\Permission;
38
use SilverStripe\Security\Security;
39
use SilverStripe\View\SSViewer;
40
41
if (!class_exists(TestCase::class)) {
42
    return;
43
}
44
45
/**
46
 * Test case class for the Sapphire framework.
47
 * Sapphire unit testing is based on PHPUnit, but provides a number of hooks into our data model that make it easier
48
 * to work with.
49
 *
50
 * This class should not be used anywhere outside of unit tests, as phpunit may not be installed
51
 * in production sites.
52
 */
53
class SapphireTest extends TestCase implements TestOnly
54
{
55
    /**
56
     * Path to fixture data for this test run.
57
     * If passed as an array, multiple fixture files will be loaded.
58
     * Please note that you won't be able to refer with "=>" notation
59
     * between the fixtures, they act independent of each other.
60
     *
61
     * @var string|array
62
     */
63
    protected static $fixture_file = '';
64
65
    /**
66
     * @var FixtureFactory
67
     */
68
    protected $fixtureFactory;
69
70
    /**
71
     * @var Boolean If set to TRUE, this will force a test database to be generated
72
     * in {@link setUp()}. Note that this flag is overruled by the presence of a
73
     * {@link $fixture_file}, which always forces a database build.
74
     *
75
     * @var bool
76
     */
77
    protected $usesDatabase = null;
78
79
    /**
80
     * @var bool
81
     */
82
    protected static $is_running_test = false;
83
84
    /**
85
     * By default, setUp() does not require default records. Pass
86
     * class names in here, and the require/augment default records
87
     * function will be called on them.
88
     *
89
     * @var array
90
     */
91
    protected $requireDefaultRecordsFrom = array();
92
93
    /**
94
     * A list of extensions that can't be applied during the execution of this run.  If they are
95
     * applied, they will be temporarily removed and a database migration called.
96
     *
97
     * The keys of the are the classes that the extensions can't be applied the extensions to, and
98
     * the values are an array of illegal extensions on that class.
99
     *
100
     * Set a class to `*` to remove all extensions (unadvised)
101
     *
102
     * @var array
103
     */
104
    protected static $illegal_extensions = [];
105
106
    /**
107
     * A list of extensions that must be applied during the execution of this run.  If they are
108
     * not applied, they will be temporarily added and a database migration called.
109
     *
110
     * The keys of the are the classes to apply the extensions to, and the values are an array
111
     * of required extensions on that class.
112
     *
113
     * Example:
114
     * <code>
115
     * array("MyTreeDataObject" => array("Versioned", "Hierarchy"))
116
     * </code>
117
     *
118
     * @var array
119
     */
120
    protected static $required_extensions = [];
121
122
    /**
123
     * By default, the test database won't contain any DataObjects that have the interface TestOnly.
124
     * This variable lets you define additional TestOnly DataObjects to set up for this test.
125
     * Set it to an array of DataObject subclass names.
126
     *
127
     * @var array
128
     */
129
    protected static $extra_dataobjects = [];
130
131
    /**
132
     * List of class names of {@see Controller} objects to register routes for
133
     * Controllers must implement Link() method
134
     *
135
     * @var array
136
     */
137
    protected static $extra_controllers = [];
138
139
    /**
140
     * We need to disabling backing up of globals to avoid overriding
141
     * the few globals SilverStripe relies on, like $lang for the i18n subsystem.
142
     *
143
     * @see http://sebastian-bergmann.de/archives/797-Global-Variables-and-PHPUnit.html
144
     */
145
    protected $backupGlobals = false;
146
147
    /**
148
     * State management container for SapphireTest
149
     *
150
     * @var TestState
151
     */
152
    protected static $state = null;
153
154
    /**
155
     * Temp database helper
156
     *
157
     * @var TempDatabase
158
     */
159
    protected static $tempDB = null;
160
161
    /**
162
     * Gets illegal extensions for this class
163
     *
164
     * @return array
165
     */
166
    public static function getIllegalExtensions() : array
167
    {
168
        return static::$illegal_extensions;
169
    }
170
171
    /**
172
     * Gets required extensions for this class
173
     *
174
     * @return array
175
     */
176
    public static function getRequiredExtensions() : array
177
    {
178
        return static::$required_extensions;
179
    }
180
181
    /**
182
     * Check if test bootstrapping has been performed. Must not be relied on
183
     * outside of unit tests.
184
     *
185
     * @return bool
186
     */
187
    protected static function is_running_test() : bool
188
    {
189
        return self::$is_running_test;
190
    }
191
192
    /**
193
     * Set test running state
194
     *
195
     * @param bool $bool
196
     */
197
    protected static function set_is_running_test(bool $bool) : void
198
    {
199
        self::$is_running_test = $bool;
200
    }
201
202
    /**
203
     * @return string|string[]
204
     */
205
    public static function get_fixture_file()
206
    {
207
        return static::$fixture_file;
208
    }
209
210
    /**
211
     * Setup  the test.
212
     * Always sets up in order:
213
     *  - Reset php state
214
     *  - Nest
215
     *  - Custom state helpers
216
     *
217
     * User code should call parent::setUp() before custom setup code
218
     */
219
    protected function setUp()
220
    {
221
        if (!defined('FRAMEWORK_PATH')) {
222
            trigger_error(
223
                'Missing constants, did you remember to include the test bootstrap in your phpunit.xml file?',
224
                E_USER_WARNING
225
            );
226
        }
227
228
        // Call state helpers
229
        static::$state->setUp($this);
230
231
        // We cannot run the tests on this abstract class.
232
        if (static::class == __CLASS__) {
233
            $this->markTestSkipped(sprintf('Skipping %s ', static::class));
234
            return;
235
        }
236
237
        // i18n needs to be set to the defaults or tests fail
238
        i18n::set_locale(i18n::config()->uninherited('default_locale'));
239
240
        // Set default timezone consistently to avoid NZ-specific dependencies
241
        date_default_timezone_set('UTC');
242
243
        Member::set_password_validator(null);
244
        Cookie::config()->update('report_errors', false);
245
        if (class_exists(RootURLController::class)) {
246
            RootURLController::reset();
247
        }
248
249
        Security::clear_database_is_ready();
250
251
        // Set up test routes
252
        $this->setUpRoutes();
253
254
        $fixtureFiles = $this->getFixturePaths();
255
256
        // Set up fixture
257
        if ($this->shouldSetupDatabaseForCurrentTest($fixtureFiles)) {
258
            if (!static::$tempDB->isUsed()) {
259
                static::$tempDB->build();
260
            }
261
262
            DataObject::singleton()->flushCache();
263
264
            static::$tempDB->clearAllData();
265
266
            foreach ($this->requireDefaultRecordsFrom as $className) {
267
                $instance = singleton($className);
268
                if (method_exists($instance, 'requireDefaultRecords')) {
269
                    $instance->requireDefaultRecords();
270
                }
271
                if (method_exists($instance, 'augmentDefaultRecords')) {
272
                    $instance->augmentDefaultRecords();
273
                }
274
            }
275
276
            foreach ($fixtureFiles as $fixtureFilePath) {
277
                $fixture = YamlFixture::create($fixtureFilePath);
278
                $fixture->writeInto($this->getFixtureFactory());
279
            }
280
281
            $this->logInWithPermission('ADMIN');
282
        }
283
284
        // turn off template debugging
285
        SSViewer::config()->update('source_file_comments', false);
286
287
        // Set up the test mailer
288
        Injector::inst()->registerService(new TestMailer(), Mailer::class);
289
        Email::config()->remove('send_all_emails_to');
290
        Email::config()->remove('send_all_emails_from');
291
        Email::config()->remove('cc_all_emails_to');
292
        Email::config()->remove('bcc_all_emails_to');
293
    }
294
295
296
297
    /**
298
     * Helper method to determine if the current test should enable a test database
299
     *
300
     * @param array|string $fixtureFiles
301
     * @return bool
302
     */
303
    protected function shouldSetupDatabaseForCurrentTest($fixtureFiles) : bool
304
    {
305
        $databaseEnabledByDefault = $fixtureFiles || $this->usesDatabase;
306
307
        return ($databaseEnabledByDefault && !$this->currentTestDisablesDatabase())
308
            || $this->currentTestEnablesDatabase();
309
    }
310
311
    /**
312
     * Helper method to check, if the current test uses the database.
313
     * This can be switched on with the annotation "@useDatabase"
314
     *
315
     * @return bool
316
     */
317
    protected function currentTestEnablesDatabase() : bool
318
    {
319
        $annotations = $this->getAnnotations();
320
321
        return array_key_exists('useDatabase', $annotations['method'])
322
            && $annotations['method']['useDatabase'][0] !== 'false';
323
    }
324
325
    /**
326
     * Helper method to check, if the current test uses the database.
327
     * This can be switched on with the annotation "@useDatabase false"
328
     *
329
     * @return bool
330
     */
331
    protected function currentTestDisablesDatabase() : bool
332
    {
333
        $annotations = $this->getAnnotations();
334
335
        return array_key_exists('useDatabase', $annotations['method'])
336
            && $annotations['method']['useDatabase'][0] === 'false';
337
    }
338
339
    /**
340
     * Called once per test case ({@link SapphireTest} subclass).
341
     * This is different to {@link setUp()}, which gets called once
342
     * per method. Useful to initialize expensive operations which
343
     * don't change state for any called method inside the test,
344
     * e.g. dynamically adding an extension. See {@link teardownAfterClass()}
345
     * for tearing down the state again.
346
     *
347
     * Always sets up in order:
348
     *  - Reset php state
349
     *  - Nest
350
     *  - Custom state helpers
351
     *
352
     * User code should call parent::setUpBeforeClass() before custom setup code
353
     *
354
     * @throws Exception
355
     */
356
    public static function setUpBeforeClass()
357
    {
358
        // Start tests
359
        static::start();
360
361
        if (!static::$state) {
362
            throw new Exception('SapphireTest failed to bootstrap!');
363
        }
364
365
        // Call state helpers
366
        static::$state->setUpOnce(static::class);
367
368
        // Build DB if we have objects
369
        if (static::getExtraDataObjects()) {
370
            DataObject::reset();
371
            static::resetDBSchema(true, true);
372
        }
373
    }
374
375
    /**
376
     * tearDown method that's called once per test class rather once per test method.
377
     *
378
     * Always sets up in order:
379
     *  - Custom state helpers
380
     *  - Unnest
381
     *  - Reset php state
382
     *
383
     * User code should call parent::tearDownAfterClass() after custom tear down code
384
     */
385
    public static function tearDownAfterClass()
386
    {
387
        // Call state helpers
388
        static::$state->tearDownOnce(static::class);
389
390
        // Reset DB schema
391
        static::resetDBSchema();
392
    }
393
394
    /**
395
     * @return FixtureFactory
396
     */
397
    public function getFixtureFactory() : FixtureFactory
398
    {
399
        if (!$this->fixtureFactory) {
400
            $this->fixtureFactory = Injector::inst()->create(FixtureFactory::class);
401
        }
402
        return $this->fixtureFactory;
403
    }
404
405
    /**
406
     * Sets a new fixture factory
407
     *
408
     * @param FixtureFactory $factory
409
     * @return $this
410
     */
411
    public function setFixtureFactory(FixtureFactory $factory) : SapphireTest
412
    {
413
        $this->fixtureFactory = $factory;
414
        return $this;
415
    }
416
417
    /**
418
     * Get the ID of an object from the fixture.
419
     *
420
     * @param string $className The data class or table name, as specified in your fixture file.  Parent classes won't work
421
     * @param string $identifier The identifier string, as provided in your fixture file
422
     * @return int
423
     */
424
    protected function idFromFixture(string $className, string $identifier) : int
425
    {
426
        $id = $this->getFixtureFactory()->getId($className, $identifier);
427
428
        if (!$id) {
429
            user_error(sprintf(
430
                "Couldn't find object '%s' (class: %s)",
431
                $identifier,
432
                $className
433
            ), E_USER_ERROR);
434
        }
435
436
        return $id;
437
    }
438
439
    /**
440
     * Return all of the IDs in the fixture of a particular class name.
441
     * Will collate all IDs form all fixtures if multiple fixtures are provided.
442
     *
443
     * @param string $className The data class or table name, as specified in your fixture file
444
     * @return array A map of fixture-identifier => object-id
445
     */
446
    protected function allFixtureIDs(string $className) : array
447
    {
448
        return $this->getFixtureFactory()->getIds($className);
449
    }
450
451
    /**
452
     * Get an object from the fixture.
453
     *
454
     * @param string $className The data class or table name, as specified in your fixture file. Parent classes won't work
455
     * @param string $identifier The identifier string, as provided in your fixture file
456
     *
457
     * @return DataObject
458
     */
459
    protected function objFromFixture(string $className, string $identifier) : DataObject
460
    {
461
        $obj = $this->getFixtureFactory()->get($className, $identifier);
462
463
        if (!$obj) {
464
            user_error(sprintf(
465
                "Couldn't find object '%s' (class: %s)",
466
                $identifier,
467
                $className
468
            ), E_USER_ERROR);
469
        }
470
471
        return $obj;
472
    }
473
474
    /**
475
     * Load a YAML fixture file into the database.
476
     * Once loaded, you can use idFromFixture() and objFromFixture() to get items from the fixture.
477
     * Doesn't clear existing fixtures.
478
     *
479
     * @param string $fixtureFile The location of the .yml fixture file, relative to the site base dir
480
     */
481
    public function loadFixture(string $fixtureFile) : void
482
    {
483
        $fixture = Injector::inst()->create(YamlFixture::class, $fixtureFile);
484
        $fixture->writeInto($this->getFixtureFactory());
485
    }
486
487
    /**
488
     * Clear all fixtures which were previously loaded through
489
     * {@link loadFixture()}
490
     */
491
    public function clearFixtures() : void
492
    {
493
        $this->getFixtureFactory()->clear();
494
    }
495
496
    /**
497
     * Useful for writing unit tests without hardcoding folder structures.
498
     *
499
     * @return string Absolute path to current class.
500
     */
501
    protected function getCurrentAbsolutePath() : string
502
    {
503
        $filename = ClassLoader::inst()->getItemPath(static::class);
504
        if (!$filename) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $filename of type false|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
505
            throw new LogicException('getItemPath returned null for ' . static::class
506
                . '. Try adding flush=1 to the test run.');
507
        }
508
        return dirname($filename);
509
    }
510
511
    /**
512
     * @return string File path relative to webroot
513
     */
514
    protected function getCurrentRelativePath() : string
515
    {
516
        $base = Director::baseFolder();
517
        $path = $this->getCurrentAbsolutePath();
518
        if (substr($path, 0, strlen($base)) == $base) {
519
            $path = preg_replace('/^\/*/', '', substr($path, strlen($base)));
520
        }
521
        return $path;
522
    }
523
524
    /**
525
     * Setup  the test.
526
     * Always sets up in order:
527
     *  - Custom state helpers
528
     *  - Unnest
529
     *  - Reset php state
530
     *
531
     * User code should call parent::tearDown() after custom tear down code
532
     */
533
    protected function tearDown()
534
    {
535
        // Reset mocked datetime
536
        DBDatetime::clear_mock_now();
537
538
        // Stop the redirection that might have been requested in the test.
539
        // Note: Ideally a clean Controller should be created for each test.
540
        // Now all tests executed in a batch share the same controller.
541
        $controller = Controller::has_curr() ? Controller::curr() : null;
542
        if ($controller && ($response = $controller->getResponse()) && $response->getHeader('Location')) {
543
            $response->setStatusCode(200);
544
            $response->removeHeader('Location');
545
        }
546
547
        // Call state helpers
548
        static::$state->tearDown($this);
549
    }
550
551
    public static function assertContains(
552
        $needle,
553
        $haystack,
554
        string $message = '',
555
        bool $ignoreCase = false,
556
        bool $checkForObjectIdentity = true,
557
        bool $checkForNonObjectIdentity = false
558
    ) : void {
559
        if ($haystack instanceof DBField) {
560
            $haystack = (string)$haystack;
561
        }
562
        parent::assertContains(
563
            $needle,
564
            $haystack,
565
            $message,
566
            $ignoreCase,
567
            $checkForObjectIdentity,
568
            $checkForNonObjectIdentity
569
        );
570
    }
571
572
    public static function assertNotContains(
573
        $needle,
574
        $haystack,
575
        string $message = '',
576
        bool $ignoreCase = false,
577
        bool $checkForObjectIdentity = true,
578
        bool $checkForNonObjectIdentity = false
579
    ) : void {
580
        if ($haystack instanceof DBField) {
581
            $haystack = (string)$haystack;
582
        }
583
        parent::assertNotContains($needle, $haystack, $message, $ignoreCase, $checkForObjectIdentity, $checkForNonObjectIdentity);
584
    }
585
586
    /**
587
     * Clear the log of emails sent
588
     *
589
     * @return bool True if emails cleared
590
     */
591
    public function clearEmails() : bool
592
    {
593
        /** @var Mailer $mailer */
594
        $mailer = Injector::inst()->get(Mailer::class);
595
        if ($mailer instanceof TestMailer) {
596
            $mailer->clearEmails();
597
            return true;
598
        }
599
        return false;
600
    }
601
602
    /**
603
     * Search for an email that was sent.
604
     * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
605
     * @param string $to
606
     * @param string $from
607
     * @param string $subject
608
     * @param string $content
609
     * @return array Contains keys: 'Type', 'To', 'From', 'Subject', 'Content', 'PlainContent', 'AttachedFiles',
610
     *               'HtmlContent'
611
     */
612
    public static function findEmail($to, $from = null, $subject = null, $content = null) : array
613
    {
614
        /** @var Mailer $mailer */
615
        $mailer = Injector::inst()->get(Mailer::class);
616
        if ($mailer instanceof TestMailer) {
617
            return $mailer->findEmail($to, $from, $subject, $content);
618
        }
619
        return [];
620
    }
621
622
    /**
623
     * Assert that the matching email was sent since the last call to clearEmails()
624
     * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
625
     *
626
     * @param string $to
627
     * @param string $from
628
     * @param string $subject
629
     * @param string $content
630
     */
631
    public static function assertEmailSent($to, $from = null, $subject = null, $content = null) : void
632
    {
633
        $found = (bool)static::findEmail($to, $from, $subject, $content);
634
635
        $infoParts = '';
636
        $withParts = array();
637
        if ($to) {
638
            $infoParts .= " to '$to'";
639
        }
640
        if ($from) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $from of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
641
            $infoParts .= " from '$from'";
642
        }
643
        if ($subject) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $subject of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
644
            $withParts[] = "subject '$subject'";
645
        }
646
        if ($content) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $content of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
647
            $withParts[] = "content '$content'";
648
        }
649
        if ($withParts) {
650
            $infoParts .= ' with ' . implode(' and ', $withParts);
651
        }
652
653
        static::assertTrue(
654
            $found,
655
            "Failed asserting that an email was sent$infoParts."
656
        );
657
    }
658
659
660
    /**
661
     * Assert that the given {@link SS_List} includes DataObjects matching the given key-value
662
     * pairs.  Each match must correspond to 1 distinct record.
663
     *
664
     * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
665
     * either pass a single pattern or an array of patterns.
666
     * @param SS_List $list The {@link SS_List} to test.
667
     * @param string $message
668
     *
669
     * Examples
670
     * --------
671
     * Check that $members includes an entry with Email = [email protected]:
672
     *      $this->assertListContains(['Email' => '[email protected]'], $members);
673
     *
674
     * Check that $members includes entries with Email = [email protected] and with
675
     * Email = [email protected]:
676
     *      $this->assertListContains([
677
     *         ['Email' => '[email protected]'],
678
     *         ['Email' => '[email protected]'],
679
     *      ], $members);
680
     */
681
    public static function assertListContains($matches, SS_List $list, $message = '') : void
682
    {
683
        if (!is_array($matches)) {
684
            throw InvalidArgumentHelper::factory(
685
                1,
686
                'array'
687
            );
688
        }
689
690
        static::assertThat(
691
            $list,
692
            new SSListContains(
693
                $matches
694
            ),
695
            $message
696
        );
697
    }
698
699
    /**
700
     * @deprecated 4.0.0:5.0.0 Use assertListContains() instead
701
     *
702
     * @param array $matches
703
     * @param $dataObjectSet
704
     */
705
    public function assertDOSContains($matches, $dataObjectSet)
706
    {
707
        Deprecation::notice('5.0', 'Use assertListContains() instead');
708
        static::assertListContains($matches, $dataObjectSet);
709
    }
710
711
    /**
712
     * Asserts that no items in a given list appear in the given dataobject list
713
     *
714
     * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
715
     * either pass a single pattern or an array of patterns.
716
     * @param SS_List $list The {@link SS_List} to test.
717
     * @param string $message
718
     *
719
     * Examples
720
     * --------
721
     * Check that $members doesn't have an entry with Email = [email protected]:
722
     *      $this->assertListNotContains(['Email' => '[email protected]'], $members);
723
     *
724
     * Check that $members doesn't have entries with Email = [email protected] and with
725
     * Email = [email protected]:
726
     *      $this->assertListNotContains([
727
     *          ['Email' => '[email protected]'],
728
     *          ['Email' => '[email protected]'],
729
     *      ], $members);
730
     */
731
    public static function assertListNotContains($matches, SS_List $list, $message = '') : void
732
    {
733
        if (!is_array($matches)) {
734
            throw InvalidArgumentHelper::factory(
735
                1,
736
                'array'
737
            );
738
        }
739
740
        $constraint =  new LogicalNot(
741
            new SSListContains(
742
                $matches
743
            )
744
        );
745
746
        static::assertThat(
747
            $list,
748
            $constraint,
749
            $message
750
        );
751
    }
752
753
    /**
754
     * @deprecated 4.0.0:5.0.0 Use assertListNotContains() instead
755
     *
756
     * @param $matches
757
     * @param $dataObjectSet
758
     */
759
    public static function assertNotDOSContains($matches, $dataObjectSet)
760
    {
761
        Deprecation::notice('5.0', 'Use assertListNotContains() instead');
762
        static::assertListNotContains($matches, $dataObjectSet);
763
    }
764
765
    /**
766
     * Assert that the given {@link SS_List} includes only DataObjects matching the given
767
     * key-value pairs.  Each match must correspond to 1 distinct record.
768
     *
769
     * Example
770
     * --------
771
     * Check that *only* the entries Sam Minnee and Ingo Schommer exist in $members.  Order doesn't
772
     * matter:
773
     *     $this->assertListEquals([
774
     *        ['FirstName' =>'Sam', 'Surname' => 'Minnee'],
775
     *        ['FirstName' => 'Ingo', 'Surname' => 'Schommer'],
776
     *      ], $members);
777
     *
778
     * @param mixed $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
779
     * either pass a single pattern or an array of patterns.
780
     * @param mixed $list The {@link SS_List} to test.
781
     * @param string $message
782
     */
783
    public static function assertListEquals($matches, SS_List $list, $message = '') : void
784
    {
785
        if (!is_array($matches)) {
786
            throw InvalidArgumentHelper::factory(
787
                1,
788
                'array'
789
            );
790
        }
791
792
        static::assertThat(
793
            $list,
794
            new SSListContainsOnly(
795
                $matches
796
            ),
797
            $message
798
        );
799
    }
800
801
    /**
802
     * @deprecated 4.0.0:5.0.0 Use assertListEquals() instead
803
     *
804
     * @param $matches
805
     * @param SS_List $dataObjectSet
806
     */
807
    public function assertDOSEquals($matches, $dataObjectSet)
808
    {
809
        Deprecation::notice('5.0', 'Use assertListEquals() instead');
810
        static::assertListEquals($matches, $dataObjectSet);
811
    }
812
813
814
    /**
815
     * Assert that the every record in the given {@link SS_List} matches the given key-value
816
     * pairs.
817
     *
818
     * Example
819
     * --------
820
     * Check that every entry in $members has a Status of 'Active':
821
     *     $this->assertListAllMatch(['Status' => 'Active'], $members);
822
     *
823
     * @param mixed $match The pattern to match.  The pattern is a map of key-value pairs.
824
     * @param mixed $list The {@link SS_List} to test.
825
     * @param string $message
826
     */
827
    public static function assertListAllMatch($match, SS_List $list, $message = '') : void
828
    {
829
        if (!is_array($match)) {
830
            throw PHPUnit_Util_InvalidArgumentHelper::factory(
0 ignored issues
show
Bug introduced by
The type SilverStripe\Dev\PHPUnit...l_InvalidArgumentHelper was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
831
                1,
832
                'array'
833
            );
834
        }
835
836
        static::assertThat(
837
            $list,
838
            new SSListContainsOnlyMatchingItems(
839
                $match
840
            ),
841
            $message
842
        );
843
    }
844
845
    /**
846
     * @deprecated 4.0.0:5.0.0 Use assertListAllMatch() instead
847
     *
848
     * @param $match
849
     * @param SS_List $dataObjectSet
850
     */
851
    public function assertDOSAllMatch($match, SS_List $dataObjectSet)
852
    {
853
        Deprecation::notice('5.0', 'Use assertListAllMatch() instead');
854
        static::assertListAllMatch($match, $dataObjectSet);
855
    }
856
857
    /**
858
     * Removes sequences of repeated whitespace characters from SQL queries
859
     * making them suitable for string comparison
860
     *
861
     * @param string $sql
862
     * @return string The cleaned and normalised SQL string
863
     */
864
    protected static function normaliseSQL($sql) : string
865
    {
866
        return trim(preg_replace('/\s+/m', ' ', $sql));
867
    }
868
869
    /**
870
     * Asserts that two SQL queries are equivalent
871
     *
872
     * @param string $expectedSQL
873
     * @param string $actualSQL
874
     * @param string $message
875
     * @param float|int $delta
876
     * @param integer $maxDepth
877
     * @param boolean $canonicalize
878
     * @param boolean $ignoreCase
879
     */
880
    public static function assertSQLEquals(
881
        $expectedSQL,
882
        $actualSQL,
883
        string $message = '',
884
        int $delta = 0,
885
        int $maxDepth = 10,
886
        bool $canonicalize = false,
887
        bool $ignoreCase = false
888
    ) : void {
889
        // Normalise SQL queries to remove patterns of repeating whitespace
890
        $expectedSQL = static::normaliseSQL($expectedSQL);
891
        $actualSQL = static::normaliseSQL($actualSQL);
892
893
        static::assertEquals($expectedSQL, $actualSQL, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
894
    }
895
896
    /**
897
     * Asserts that a SQL query contains a SQL fragment
898
     *
899
     * @param string $needleSQL
900
     * @param string $haystackSQL
901
     * @param string $message
902
     * @param boolean $ignoreCase
903
     * @param boolean $checkForObjectIdentity
904
     */
905
    public static function assertSQLContains(
906
        $needleSQL,
907
        $haystackSQL,
908
        string $message = '',
909
        bool $ignoreCase = false,
910
        bool $checkForObjectIdentity = true
911
    ) : void {
912
        $needleSQL = static::normaliseSQL($needleSQL);
913
        $haystackSQL = static::normaliseSQL($haystackSQL);
914
915
        static::assertContains($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
916
    }
917
918
    /**
919
     * Asserts that a SQL query contains a SQL fragment
920
     *
921
     * @param string $needleSQL
922
     * @param string $haystackSQL
923
     * @param string $message
924
     * @param boolean $ignoreCase
925
     * @param boolean $checkForObjectIdentity
926
     */
927
    public static function assertSQLNotContains(
928
        $needleSQL,
929
        $haystackSQL,
930
        string $message = '',
931
        bool $ignoreCase = false,
932
        bool $checkForObjectIdentity = true
933
    ) : void {
934
        $needleSQL = static::normaliseSQL($needleSQL);
935
        $haystackSQL = static::normaliseSQL($haystackSQL);
936
937
        static::assertNotContains($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
938
    }
939
940
    /**
941
     * Start test environment
942
     */
943
    public static function start() : void
944
    {
945
        if (static::is_running_test()) {
946
            return;
947
        }
948
949
        // Health check
950
        if (InjectorLoader::inst()->countManifests()) {
951
            throw new LogicException('SapphireTest::start() cannot be called within another application');
952
        }
953
        static::set_is_running_test(true);
954
955
        // Mock request
956
        $_SERVER['argv'] = ['vendor/bin/phpunit', '/'];
957
        $request = CLIRequestBuilder::createFromEnvironment();
958
959
        // Test application
960
        $kernel = new TestKernel(BASE_PATH);
961
        $app = new HTTPApplication($kernel);
962
963
        // Custom application
964
        $app->execute($request, function (HTTPRequest $request) {
965
            // Start session and execute
966
            $request->getSession()->init($request);
967
968
            // Invalidate classname spec since the test manifest will now pull out new subclasses for each internal class
969
            // (e.g. Member will now have various subclasses of DataObjects that implement TestOnly)
970
            DataObject::reset();
971
972
            // Set dummy controller;
973
            $controller = Controller::create();
974
            $controller->setRequest($request);
975
            $controller->pushCurrent();
976
            $controller->doInit();
977
        }, true);
978
979
        // Register state
980
        static::$state = SapphireTestState::singleton();
981
        // Register temp DB holder
982
        static::$tempDB = TempDatabase::create();
983
    }
984
985
    /**
986
     * Reset the testing database's schema, but only if it is active
987
     * @param bool $includeExtraDataObjects If true, the extraDataObjects tables will also be included
988
     * @param bool $forceCreate Force DB to be created if it doesn't exist
989
     */
990
    public static function resetDBSchema(bool $includeExtraDataObjects = false, bool $forceCreate = false) : void
991
    {
992
        // Check if DB is active before reset
993
        if (!static::$tempDB->isUsed()) {
994
            if (!$forceCreate) {
995
                return;
996
            }
997
            static::$tempDB->build();
998
        }
999
        $extraDataObjects = $includeExtraDataObjects ? static::getExtraDataObjects() : [];
1000
        static::$tempDB->resetDBSchema((array)$extraDataObjects);
1001
    }
1002
1003
    /**
1004
     * A wrapper for automatically performing callbacks as a user with a specific permission
1005
     *
1006
     * @param string|array $permCode
1007
     * @param callable $callback
1008
     * @return mixed
1009
     */
1010
    public function actWithPermission($permCode, callable $callback)
1011
    {
1012
        return Member::actAs($this->createMemberWithPermission($permCode), $callback);
1013
    }
1014
1015
    /**
1016
     * Create Member and Group objects on demand with specific permission code
1017
     *
1018
     * @param string|array $permCode
1019
     * @return Member
1020
     */
1021
    protected function createMemberWithPermission($permCode) : Member
1022
    {
1023
        if (is_array($permCode)) {
1024
            $permArray = $permCode;
1025
            $permCode = implode('.', $permCode);
1026
        } else {
1027
            $permArray = array($permCode);
1028
        }
1029
1030
        // Check cached member
1031
        if (isset($this->cache_generatedMembers[$permCode])) {
1032
            $member = $this->cache_generatedMembers[$permCode];
1033
        } else {
1034
            // Generate group with these permissions
1035
            $group = Group::create();
1036
            $group->Title = "$permCode group";
1037
            $group->write();
1038
1039
            // Create each individual permission
1040
            foreach ($permArray as $permArrayItem) {
1041
                $permission = Permission::create();
1042
                $permission->Code = $permArrayItem;
1043
                $permission->write();
1044
                $group->Permissions()->add($permission);
1045
            }
1046
1047
            $member = Member::get()->filter([
1048
                'Email' => "[email protected]",
1049
            ])->first();
1050
            if (!$member) {
1051
                $member = Member::create();
1052
            }
1053
1054
            $member->FirstName = $permCode;
1055
            $member->Surname = 'User';
1056
            $member->Email = "[email protected]";
1057
            $member->write();
1058
            $group->Members()->add($member);
1059
1060
            $this->cache_generatedMembers[$permCode] = $member;
1061
        }
1062
        return $member;
1063
    }
1064
1065
    /**
1066
     * Create a member and group with the given permission code, and log in with it.
1067
     * Returns the member ID.
1068
     *
1069
     * @param string|array $permCode Either a permission, or list of permissions
1070
     * @return int Member ID
1071
     */
1072
    public function logInWithPermission($permCode = 'ADMIN')
1073
    {
1074
        $member = $this->createMemberWithPermission($permCode);
1075
        $this->logInAs($member);
1076
        return $member->ID;
1077
    }
1078
1079
    /**
1080
     * Log in as the given member
1081
     *
1082
     * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in
1083
     */
1084
    public function logInAs($member) : void
1085
    {
1086
        if (is_numeric($member)) {
1087
            $member = DataObject::get_by_id(Member::class, $member);
0 ignored issues
show
Bug introduced by
It seems like $member can also be of type string; however, parameter $id of SilverStripe\ORM\DataObject::get_by_id() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

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

1087
            $member = DataObject::get_by_id(Member::class, /** @scrutinizer ignore-type */ $member);
Loading history...
1088
        } elseif (!is_object($member)) {
1089
            $member = $this->objFromFixture(Member::class, $member);
1090
        }
1091
        Injector::inst()->get(IdentityStore::class)->logIn($member);
1092
    }
1093
1094
    /**
1095
     * Log out the current user
1096
     */
1097
    public function logOut() : void
1098
    {
1099
        /** @var IdentityStore $store */
1100
        $store = Injector::inst()->get(IdentityStore::class);
1101
        $store->logOut();
1102
    }
1103
1104
    /**
1105
     * Cache for logInWithPermission()
1106
     */
1107
    protected $cache_generatedMembers = [];
1108
1109
    /**
1110
     * Test against a theme.
1111
     *
1112
     * @param string $themeBaseDir themes directory
1113
     * @param string $theme Theme name
1114
     * @param callable $callback
1115
     * @throws Exception
1116
     */
1117
    protected function useTestTheme(string $themeBaseDir, string $theme, callable $callback) : void
1118
    {
1119
        Config::nest();
1120
        if (strpos($themeBaseDir, BASE_PATH) === 0) {
1121
            $themeBaseDir = substr($themeBaseDir, strlen(BASE_PATH));
1122
        }
1123
        SSViewer::config()->update('theme_enabled', true);
1124
        SSViewer::set_themes([$themeBaseDir.'/themes/'.$theme, '$default']);
1125
1126
        try {
1127
            $callback();
1128
        } finally {
1129
            Config::unnest();
1130
        }
1131
    }
1132
1133
    /**
1134
     * Get fixture paths for this test
1135
     *
1136
     * @return array List of paths
1137
     */
1138
    protected function getFixturePaths() : array
1139
    {
1140
        $fixtureFile = static::get_fixture_file();
1141
        if (empty($fixtureFile)) {
1142
            return [];
1143
        }
1144
1145
        $fixtureFiles = is_array($fixtureFile) ? $fixtureFile : [$fixtureFile];
1146
1147
        return array_map(function ($fixtureFilePath) {
1148
            return $this->resolveFixturePath($fixtureFilePath);
1149
        }, $fixtureFiles);
1150
    }
1151
1152
    /**
1153
     * Return all extra objects to scaffold for this test
1154
     * @return array
1155
     */
1156
    public static function getExtraDataObjects()
1157
    {
1158
        return static::$extra_dataobjects;
1159
    }
1160
1161
    /**
1162
     * Get additional controller classes to register routes for
1163
     *
1164
     * @return array
1165
     */
1166
    public static function getExtraControllers() : array
1167
    {
1168
        return static::$extra_controllers;
1169
    }
1170
1171
    /**
1172
     * Map a fixture path to a physical file
1173
     *
1174
     * @param string $fixtureFilePath
1175
     * @return string
1176
     */
1177
    protected function resolveFixturePath(string $fixtureFilePath) : string
1178
    {
1179
        // Support fixture paths relative to the test class, rather than relative to webroot
1180
        // String checking is faster than file_exists() calls.
1181
        $isRelativeToFile
1182
            = (strpos('/', $fixtureFilePath) === false)
1183
            || preg_match('/^(\.){1,2}/', $fixtureFilePath);
1184
1185
        if ($isRelativeToFile) {
1186
            $resolvedPath = realpath($this->getCurrentAbsolutePath() . '/' . $fixtureFilePath);
1187
            if ($resolvedPath) {
1188
                return $resolvedPath;
1189
            }
1190
        }
1191
1192
        // Check if file exists relative to base dir
1193
        $resolvedPath = realpath(Director::baseFolder() . '/' . $fixtureFilePath);
1194
        if ($resolvedPath) {
1195
            return $resolvedPath;
1196
        }
1197
1198
        return $fixtureFilePath;
1199
    }
1200
1201
    protected function setUpRoutes()
1202
    {
1203
        // Get overridden routes
1204
        $rules = $this->getExtraRoutes();
1205
1206
        // Add all other routes
1207
        foreach (Director::config()->uninherited('rules') as $route => $rule) {
1208
            if (!isset($rules[$route])) {
1209
                $rules[$route] = $rule;
1210
            }
1211
        }
1212
1213
        // Add default catch-all rule
1214
        $rules['$Controller//$Action/$ID/$OtherID'] = '*';
1215
1216
        // Add controller-name auto-routing
1217
        Director::config()->set('rules', $rules);
1218
    }
1219
1220
    /**
1221
     * Get extra routes to merge into Director.rules
1222
     *
1223
     * @return array
1224
     */
1225
    protected function getExtraRoutes()
1226
    {
1227
        $rules = [];
1228
        foreach ($this->getExtraControllers() as $class) {
1229
            $controllerInst = Controller::singleton($class);
1230
            $link = Director::makeRelative($controllerInst->Link());
1231
            $route = rtrim($link, '/') . '//$Action/$ID/$OtherID';
1232
            $rules[$route] = $class;
1233
        }
1234
        return $rules;
1235
    }
1236
}
1237