Completed
Push — master ( 4ad6bd...3873e4 )
by Ingo
11:53
created

SapphireTest::resetDBSchema()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 7
nc 5
nop 2
dl 0
loc 12
rs 9.2
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 48 and the first side effect is on line 37.

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_TestCase;
8
use SilverStripe\CMS\Controllers\RootURLController;
9
use SilverStripe\Control\CLIRequestBuilder;
10
use SilverStripe\Control\Controller;
11
use SilverStripe\Control\Cookie;
12
use SilverStripe\Control\Director;
13
use SilverStripe\Control\Email\Email;
14
use SilverStripe\Control\Email\Mailer;
15
use SilverStripe\Control\HTTPRequest;
16
use SilverStripe\Core\Config\Config;
17
use SilverStripe\Control\HTTPApplication;
18
use SilverStripe\Core\Injector\Injector;
19
use SilverStripe\Core\Injector\InjectorLoader;
20
use SilverStripe\Core\Manifest\ClassLoader;
21
use SilverStripe\Dev\State\SapphireTestState;
22
use SilverStripe\Dev\State\TestState;
23
use SilverStripe\i18n\i18n;
24
use SilverStripe\ORM\Connect\TempDatabase;
25
use SilverStripe\ORM\DataObject;
26
use SilverStripe\ORM\FieldType\DBDatetime;
27
use SilverStripe\ORM\FieldType\DBField;
28
use SilverStripe\ORM\SS_List;
29
use SilverStripe\Security\Group;
30
use SilverStripe\Security\IdentityStore;
31
use SilverStripe\Security\Member;
32
use SilverStripe\Security\Permission;
33
use SilverStripe\Security\Security;
34
use SilverStripe\View\SSViewer;
35
36
if (!class_exists(PHPUnit_Framework_TestCase::class)) {
37
    return;
38
}
39
40
/**
41
 * Test case class for the Sapphire framework.
42
 * Sapphire unit testing is based on PHPUnit, but provides a number of hooks into our data model that make it easier
43
 * to work with.
44
 *
45
 * This class should not be used anywhere outside of unit tests, as phpunit may not be installed
46
 * in production sites.
47
 */
48
class SapphireTest extends PHPUnit_Framework_TestCase implements TestOnly
49
{
50
    /**
51
     * Path to fixture data for this test run.
52
     * If passed as an array, multiple fixture files will be loaded.
53
     * Please note that you won't be able to refer with "=>" notation
54
     * between the fixtures, they act independent of each other.
55
     *
56
     * @var string|array
57
     */
58
    protected static $fixture_file = null;
59
60
    /**
61
     * @var FixtureFactory
62
     */
63
    protected $fixtureFactory;
64
65
    /**
66
     * @var Boolean If set to TRUE, this will force a test database to be generated
67
     * in {@link setUp()}. Note that this flag is overruled by the presence of a
68
     * {@link $fixture_file}, which always forces a database build.
69
     *
70
     * @var bool
71
     */
72
    protected $usesDatabase = null;
73
74
    /**
75
     * @var bool
76
     */
77
    protected static $is_running_test = false;
78
79
    /**
80
     * By default, setUp() does not require default records. Pass
81
     * class names in here, and the require/augment default records
82
     * function will be called on them.
83
     *
84
     * @var array
85
     */
86
    protected $requireDefaultRecordsFrom = array();
87
88
    /**
89
     * A list of extensions that can't be applied during the execution of this run.  If they are
90
     * applied, they will be temporarily removed and a database migration called.
91
     *
92
     * The keys of the are the classes that the extensions can't be applied the extensions to, and
93
     * the values are an array of illegal extensions on that class.
94
     *
95
     * Set a class to `*` to remove all extensions (unadvised)
96
     *
97
     * @var array
98
     */
99
    protected static $illegal_extensions = [];
100
101
    /**
102
     * A list of extensions that must be applied during the execution of this run.  If they are
103
     * not applied, they will be temporarily added and a database migration called.
104
     *
105
     * The keys of the are the classes to apply the extensions to, and the values are an array
106
     * of required extensions on that class.
107
     *
108
     * Example:
109
     * <code>
110
     * array("MyTreeDataObject" => array("Versioned", "Hierarchy"))
111
     * </code>
112
     *
113
     * @var array
114
     */
115
    protected static $required_extensions = [];
116
117
    /**
118
     * By default, the test database won't contain any DataObjects that have the interface TestOnly.
119
     * This variable lets you define additional TestOnly DataObjects to set up for this test.
120
     * Set it to an array of DataObject subclass names.
121
     *
122
     * @var array
123
     */
124
    protected static $extra_dataobjects = [];
125
126
    /**
127
     * List of class names of {@see Controller} objects to register routes for
128
     * Controllers must implement Link() method
129
     *
130
     * @var array
131
     */
132
    protected static $extra_controllers = [];
133
134
    /**
135
     * We need to disabling backing up of globals to avoid overriding
136
     * the few globals SilverStripe relies on, like $lang for the i18n subsystem.
137
     *
138
     * @see http://sebastian-bergmann.de/archives/797-Global-Variables-and-PHPUnit.html
139
     */
140
    protected $backupGlobals = false;
141
142
    /**
143
     * State management container for SapphireTest
144
     *
145
     * @var TestState
146
     */
147
    protected static $state = null;
148
149
    /**
150
     * Temp database helper
151
     *
152
     * @var TempDatabase
153
     */
154
    protected static $tempDB = null;
155
156
    /**
157
     * Gets illegal extensions for this class
158
     *
159
     * @return array
160
     */
161
    public static function getIllegalExtensions()
162
    {
163
        return static::$illegal_extensions;
164
    }
165
166
    /**
167
     * Gets required extensions for this class
168
     *
169
     * @return array
170
     */
171
    public static function getRequiredExtensions()
172
    {
173
        return static::$required_extensions;
174
    }
175
176
    /**
177
     * Check if test bootstrapping has been performed. Must not be relied on
178
     * outside of unit tests.
179
     *
180
     * @return bool
181
     */
182
    protected static function is_running_test()
183
    {
184
        return self::$is_running_test;
185
    }
186
187
    /**
188
     * Set test running state
189
     *
190
     * @param bool $bool
191
     */
192
    protected static function set_is_running_test($bool)
193
    {
194
        self::$is_running_test = $bool;
195
    }
196
197
    /**
198
     * @return String
199
     */
200
    public static function get_fixture_file()
201
    {
202
        return static::$fixture_file;
203
    }
204
205
    /**
206
     * Setup  the test.
207
     * Always sets up in order:
208
     *  - Reset php state
209
     *  - Nest
210
     *  - Custom state helpers
211
     *
212
     * User code should call parent::setUp() before custom setup code
213
     */
214
    protected function setUp()
215
    {
216
        // Call state helpers
217
        static::$state->setUp($this);
218
219
        // We cannot run the tests on this abstract class.
220
        if (static::class == __CLASS__) {
221
            $this->markTestSkipped(sprintf('Skipping %s ', static::class));
222
            return;
223
        }
224
225
        // i18n needs to be set to the defaults or tests fail
226
        i18n::set_locale(i18n::config()->uninherited('default_locale'));
227
228
        // Set default timezone consistently to avoid NZ-specific dependencies
229
        date_default_timezone_set('UTC');
230
231
        Member::set_password_validator(null);
232
        Cookie::config()->update('report_errors', false);
233
        if (class_exists(RootURLController::class)) {
234
            RootURLController::reset();
235
        }
236
237
        Security::clear_database_is_ready();
238
239
        // Set up test routes
240
        $this->setUpRoutes();
241
242
        $fixtureFiles = $this->getFixturePaths();
243
244
        // Set up fixture
245
        if ($fixtureFiles || $this->usesDatabase) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fixtureFiles of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
246
            if (!static::$tempDB->isUsed()) {
247
                static::$tempDB->build();
248
            }
249
250
            DataObject::singleton()->flushCache();
251
252
            static::$tempDB->clearAllData();
253
254
            foreach ($this->requireDefaultRecordsFrom as $className) {
255
                $instance = singleton($className);
256
                if (method_exists($instance, 'requireDefaultRecords')) {
257
                    $instance->requireDefaultRecords();
258
                }
259
                if (method_exists($instance, 'augmentDefaultRecords')) {
260
                    $instance->augmentDefaultRecords();
261
                }
262
            }
263
264
            foreach ($fixtureFiles as $fixtureFilePath) {
265
                $fixture = YamlFixture::create($fixtureFilePath);
266
                $fixture->writeInto($this->getFixtureFactory());
267
            }
268
269
            $this->logInWithPermission("ADMIN");
270
        }
271
272
        // turn off template debugging
273
        SSViewer::config()->update('source_file_comments', false);
274
275
        // Set up the test mailer
276
        Injector::inst()->registerService(new TestMailer(), Mailer::class);
277
        Email::config()->remove('send_all_emails_to');
278
        Email::config()->remove('send_all_emails_from');
279
        Email::config()->remove('cc_all_emails_to');
280
        Email::config()->remove('bcc_all_emails_to');
281
    }
282
283
    /**
284
     * Called once per test case ({@link SapphireTest} subclass).
285
     * This is different to {@link setUp()}, which gets called once
286
     * per method. Useful to initialize expensive operations which
287
     * don't change state for any called method inside the test,
288
     * e.g. dynamically adding an extension. See {@link teardownAfterClass()}
289
     * for tearing down the state again.
290
     *
291
     * Always sets up in order:
292
     *  - Reset php state
293
     *  - Nest
294
     *  - Custom state helpers
295
     *
296
     * User code should call parent::setUpBeforeClass() before custom setup code
297
     */
298
    public static function setUpBeforeClass()
299
    {
300
        // Start tests
301
        static::start();
302
303
        // Call state helpers
304
        static::$state->setUpOnce(static::class);
305
306
        // Build DB if we have objects
307
        if (static::getExtraDataObjects()) {
308
            DataObject::reset();
309
            static::resetDBSchema(true, true);
310
        }
311
    }
312
313
    /**
314
     * tearDown method that's called once per test class rather once per test method.
315
     *
316
     * Always sets up in order:
317
     *  - Custom state helpers
318
     *  - Unnest
319
     *  - Reset php state
320
     *
321
     * User code should call parent::tearDownAfterClass() after custom tear down code
322
     */
323
    public static function tearDownAfterClass()
324
    {
325
        // Call state helpers
326
        static::$state->tearDownOnce(static::class);
327
328
        // Reset DB schema
329
        static::resetDBSchema();
330
    }
331
332
    /**
333
     * @return FixtureFactory
334
     */
335
    public function getFixtureFactory()
336
    {
337
        if (!$this->fixtureFactory) {
338
            $this->fixtureFactory = Injector::inst()->create(FixtureFactory::class);
339
        }
340
        return $this->fixtureFactory;
341
    }
342
343
    /**
344
     * Sets a new fixture factory
345
     *
346
     * @param FixtureFactory $factory
347
     * @return $this
348
     */
349
    public function setFixtureFactory(FixtureFactory $factory)
350
    {
351
        $this->fixtureFactory = $factory;
352
        return $this;
353
    }
354
355
    /**
356
     * Get the ID of an object from the fixture.
357
     *
358
     * @param string $className The data class or table name, as specified in your fixture file.  Parent classes won't work
359
     * @param string $identifier The identifier string, as provided in your fixture file
360
     * @return int
361
     */
362
    protected function idFromFixture($className, $identifier)
363
    {
364
        $id = $this->getFixtureFactory()->getId($className, $identifier);
365
366
        if (!$id) {
367
            user_error(sprintf(
368
                "Couldn't find object '%s' (class: %s)",
369
                $identifier,
370
                $className
371
            ), E_USER_ERROR);
372
        }
373
374
        return $id;
375
    }
376
377
    /**
378
     * Return all of the IDs in the fixture of a particular class name.
379
     * Will collate all IDs form all fixtures if multiple fixtures are provided.
380
     *
381
     * @param string $className The data class or table name, as specified in your fixture file
382
     * @return array A map of fixture-identifier => object-id
383
     */
384
    protected function allFixtureIDs($className)
385
    {
386
        return $this->getFixtureFactory()->getIds($className);
387
    }
388
389
    /**
390
     * Get an object from the fixture.
391
     *
392
     * @param string $className The data class or table name, as specified in your fixture file. Parent classes won't work
393
     * @param string $identifier The identifier string, as provided in your fixture file
394
     *
395
     * @return DataObject
396
     */
397
    protected function objFromFixture($className, $identifier)
398
    {
399
        $obj = $this->getFixtureFactory()->get($className, $identifier);
400
401
        if (!$obj) {
402
            user_error(sprintf(
403
                "Couldn't find object '%s' (class: %s)",
404
                $identifier,
405
                $className
406
            ), E_USER_ERROR);
407
        }
408
409
        return $obj;
410
    }
411
412
    /**
413
     * Load a YAML fixture file into the database.
414
     * Once loaded, you can use idFromFixture() and objFromFixture() to get items from the fixture.
415
     * Doesn't clear existing fixtures.
416
     *
417
     * @param string $fixtureFile The location of the .yml fixture file, relative to the site base dir
418
     */
419
    public function loadFixture($fixtureFile)
420
    {
421
        $fixture = Injector::inst()->create(YamlFixture::class, $fixtureFile);
422
        $fixture->writeInto($this->getFixtureFactory());
423
    }
424
425
    /**
426
     * Clear all fixtures which were previously loaded through
427
     * {@link loadFixture()}
428
     */
429
    public function clearFixtures()
430
    {
431
        $this->getFixtureFactory()->clear();
432
    }
433
434
    /**
435
     * Useful for writing unit tests without hardcoding folder structures.
436
     *
437
     * @return string Absolute path to current class.
438
     */
439
    protected function getCurrentAbsolutePath()
440
    {
441
        $filename = ClassLoader::inst()->getItemPath(static::class);
442
        if (!$filename) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $filename of type string|false 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...
443
            throw new LogicException("getItemPath returned null for " . static::class);
444
        }
445
        return dirname($filename);
446
    }
447
448
    /**
449
     * @return string File path relative to webroot
450
     */
451
    protected function getCurrentRelativePath()
452
    {
453
        $base = Director::baseFolder();
454
        $path = $this->getCurrentAbsolutePath();
455
        if (substr($path, 0, strlen($base)) == $base) {
456
            $path = preg_replace('/^\/*/', '', substr($path, strlen($base)));
457
        }
458
        return $path;
459
    }
460
461
    /**
462
     * Setup  the test.
463
     * Always sets up in order:
464
     *  - Custom state helpers
465
     *  - Unnest
466
     *  - Reset php state
467
     *
468
     * User code should call parent::tearDown() after custom tear down code
469
     */
470
    protected function tearDown()
471
    {
472
        // Reset mocked datetime
473
        DBDatetime::clear_mock_now();
474
475
        // Stop the redirection that might have been requested in the test.
476
        // Note: Ideally a clean Controller should be created for each test.
477
        // Now all tests executed in a batch share the same controller.
478
        $controller = Controller::has_curr() ? Controller::curr() : null;
479
        if ($controller && ($response = $controller->getResponse()) && $response->getHeader('Location')) {
480
            $response->setStatusCode(200);
481
            $response->removeHeader('Location');
482
        }
483
484
        // Call state helpers
485
        static::$state->tearDown($this);
486
    }
487
488
    public static function assertContains(
489
        $needle,
490
        $haystack,
491
        $message = '',
492
        $ignoreCase = false,
493
        $checkForObjectIdentity = true,
494
        $checkForNonObjectIdentity = false
495
    ) {
496
        if ($haystack instanceof DBField) {
497
            $haystack = (string)$haystack;
498
        }
499
        parent::assertContains($needle, $haystack, $message, $ignoreCase, $checkForObjectIdentity, $checkForNonObjectIdentity);
500
    }
501
502
    public static function assertNotContains(
503
        $needle,
504
        $haystack,
505
        $message = '',
506
        $ignoreCase = false,
507
        $checkForObjectIdentity = true,
508
        $checkForNonObjectIdentity = false
509
    ) {
510
        if ($haystack instanceof DBField) {
511
            $haystack = (string)$haystack;
512
        }
513
        parent::assertNotContains($needle, $haystack, $message, $ignoreCase, $checkForObjectIdentity, $checkForNonObjectIdentity);
514
    }
515
516
    /**
517
     * Clear the log of emails sent
518
     *
519
     * @return bool True if emails cleared
520
     */
521
    public function clearEmails()
522
    {
523
        /** @var Mailer $mailer */
524
        $mailer = Injector::inst()->get(Mailer::class);
525
        if ($mailer instanceof TestMailer) {
526
            $mailer->clearEmails();
527
            return true;
528
        }
529
        return false;
530
    }
531
532
    /**
533
     * Search for an email that was sent.
534
     * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
535
     * @param string $to
536
     * @param string $from
537
     * @param string $subject
538
     * @param string $content
539
     * @return array Contains keys: 'type', 'to', 'from', 'subject','content', 'plainContent', 'attachedFiles',
540
     *               'customHeaders', 'htmlContent', 'inlineImages'
541
     */
542
    public function findEmail($to, $from = null, $subject = null, $content = null)
543
    {
544
        /** @var Mailer $mailer */
545
        $mailer = Injector::inst()->get(Mailer::class);
546
        if ($mailer instanceof TestMailer) {
547
            return $mailer->findEmail($to, $from, $subject, $content);
548
        }
549
        return null;
550
    }
551
552
    /**
553
     * Assert that the matching email was sent since the last call to clearEmails()
554
     * All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
555
     *
556
     * @param string $to
557
     * @param string $from
558
     * @param string $subject
559
     * @param string $content
560
     */
561
    public function assertEmailSent($to, $from = null, $subject = null, $content = null)
562
    {
563
        $found = (bool)$this->findEmail($to, $from, $subject, $content);
564
565
        $infoParts = "";
566
        $withParts = array();
567
        if ($to) {
568
            $infoParts .= " to '$to'";
569
        }
570
        if ($from) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $from of type string|null 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...
571
            $infoParts .= " from '$from'";
572
        }
573
        if ($subject) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $subject of type string|null 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...
574
            $withParts[] = "subject '$subject'";
575
        }
576
        if ($content) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $content of type string|null 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...
577
            $withParts[] = "content '$content'";
578
        }
579
        if ($withParts) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $withParts of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
580
            $infoParts .= " with " . implode(" and ", $withParts);
581
        }
582
583
        $this->assertTrue(
584
            $found,
585
            "Failed asserting that an email was sent$infoParts."
586
        );
587
    }
588
589
590
    /**
591
     * Assert that the given {@link SS_List} includes DataObjects matching the given key-value
592
     * pairs.  Each match must correspond to 1 distinct record.
593
     *
594
     * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
595
     * either pass a single pattern or an array of patterns.
596
     * @param SS_List $dataObjectSet The {@link SS_List} to test.
597
     *
598
     * Examples
599
     * --------
600
     * Check that $members includes an entry with Email = [email protected]:
601
     *      $this->assertDOSContains(array('Email' => '[email protected]'), $members);
602
     *
603
     * Check that $members includes entries with Email = [email protected] and with
604
     * Email = [email protected]:
605
     *      $this->assertDOSContains(array(
606
     *         array('Email' => '[email protected]'),
607
     *         array('Email' => '[email protected]'),
608
     *      ), $members);
609
     */
610
    public function assertDOSContains($matches, $dataObjectSet)
611
    {
612
        $extracted = array();
613
        foreach ($dataObjectSet as $object) {
614
            /** @var DataObject $object */
615
            $extracted[] = $object->toMap();
616
        }
617
618
        foreach ($matches as $match) {
619
            $matched = false;
620
            foreach ($extracted as $i => $item) {
621
                if ($this->dataObjectArrayMatch($item, $match)) {
622
                    // Remove it from $extracted so that we don't get duplicate mapping.
623
                    unset($extracted[$i]);
624
                    $matched = true;
625
                    break;
626
                }
627
            }
628
629
            // We couldn't find a match - assertion failed
630
            $this->assertTrue(
631
                $matched,
632
                "Failed asserting that the SS_List contains an item matching "
633
                . var_export($match, true) . "\n\nIn the following SS_List:\n"
634
                . $this->DOSSummaryForMatch($dataObjectSet, $match)
635
            );
636
        }
637
    }
638
    /**
639
     * Asserts that no items in a given list appear in the given dataobject list
640
     *
641
     * @param SS_List|array $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
642
     * either pass a single pattern or an array of patterns.
643
     * @param SS_List $dataObjectSet The {@link SS_List} to test.
644
     *
645
     * Examples
646
     * --------
647
     * Check that $members doesn't have an entry with Email = [email protected]:
648
     *      $this->assertNotDOSContains(array('Email' => '[email protected]'), $members);
649
     *
650
     * Check that $members doesn't have entries with Email = [email protected] and with
651
     * Email = [email protected]:
652
     *      $this->assertNotDOSContains(array(
653
     *         array('Email' => '[email protected]'),
654
     *         array('Email' => '[email protected]'),
655
     *      ), $members);
656
     */
657
    public function assertNotDOSContains($matches, $dataObjectSet)
658
    {
659
        $extracted = array();
660
        foreach ($dataObjectSet as $object) {
661
            /** @var DataObject $object */
662
            $extracted[] = $object->toMap();
663
        }
664
665
        $matched = [];
666
        foreach ($matches as $match) {
667
            foreach ($extracted as $i => $item) {
668
                if ($this->dataObjectArrayMatch($item, $match)) {
669
                    $matched[] = $extracted[$i];
670
                    break;
671
                }
672
            }
673
674
            // We couldn't find a match - assertion failed
675
            $this->assertEmpty(
676
                $matched,
677
                "Failed asserting that the SS_List dosn't contain a set of objects. "
678
                . "Found objects were: " . var_export($matched, true)
679
            );
680
        }
681
    }
682
683
    /**
684
     * Assert that the given {@link SS_List} includes only DataObjects matching the given
685
     * key-value pairs.  Each match must correspond to 1 distinct record.
686
     *
687
     * Example
688
     * --------
689
     * Check that *only* the entries Sam Minnee and Ingo Schommer exist in $members.  Order doesn't
690
     * matter:
691
     *     $this->assertDOSEquals(array(
692
     *        array('FirstName' =>'Sam', 'Surname' => 'Minnee'),
693
     *        array('FirstName' => 'Ingo', 'Surname' => 'Schommer'),
694
     *      ), $members);
695
     *
696
     * @param mixed $matches The patterns to match.  Each pattern is a map of key-value pairs.  You can
697
     * either pass a single pattern or an array of patterns.
698
     * @param mixed $dataObjectSet The {@link SS_List} to test.
699
     */
700
    public function assertDOSEquals($matches, $dataObjectSet)
701
    {
702
        // Extract dataobjects
703
        $extracted = array();
704
        if ($dataObjectSet) {
705
            foreach ($dataObjectSet as $object) {
706
                /** @var DataObject $object */
707
                $extracted[] = $object->toMap();
708
            }
709
        }
710
711
        // Check all matches
712
        if ($matches) {
713
            foreach ($matches as $match) {
714
                $matched = false;
715
                foreach ($extracted as $i => $item) {
716
                    if ($this->dataObjectArrayMatch($item, $match)) {
717
                        // Remove it from $extracted so that we don't get duplicate mapping.
718
                        unset($extracted[$i]);
719
                        $matched = true;
720
                        break;
721
                    }
722
                }
723
724
                // We couldn't find a match - assertion failed
725
                $this->assertTrue(
726
                    $matched,
727
                    "Failed asserting that the SS_List contains an item matching "
728
                    . var_export($match, true) . "\n\nIn the following SS_List:\n"
729
                    . $this->DOSSummaryForMatch($dataObjectSet, $match)
730
                );
731
            }
732
        }
733
734
        // If we have leftovers than the DOS has extra data that shouldn't be there
735
        $this->assertTrue(
736
            (count($extracted) == 0),
737
            // If we didn't break by this point then we couldn't find a match
738
            "Failed asserting that the SS_List contained only the given items, the "
739
            . "following items were left over:\n" . var_export($extracted, true)
740
        );
741
    }
742
743
    /**
744
     * Assert that the every record in the given {@link SS_List} matches the given key-value
745
     * pairs.
746
     *
747
     * Example
748
     * --------
749
     * Check that every entry in $members has a Status of 'Active':
750
     *     $this->assertDOSAllMatch(array('Status' => 'Active'), $members);
751
     *
752
     * @param mixed $match The pattern to match.  The pattern is a map of key-value pairs.
753
     * @param mixed $dataObjectSet The {@link SS_List} to test.
754
     */
755
    public function assertDOSAllMatch($match, $dataObjectSet)
756
    {
757
        $extracted = array();
758
        foreach ($dataObjectSet as $object) {
759
            /** @var DataObject $object */
760
            $extracted[] = $object->toMap();
761
        }
762
763
        foreach ($extracted as $i => $item) {
764
            $this->assertTrue(
765
                $this->dataObjectArrayMatch($item, $match),
766
                "Failed asserting that the the following item matched "
767
                . var_export($match, true) . ": " . var_export($item, true)
768
            );
769
        }
770
    }
771
772
    /**
773
     * Removes sequences of repeated whitespace characters from SQL queries
774
     * making them suitable for string comparison
775
     *
776
     * @param string $sql
777
     * @return string The cleaned and normalised SQL string
778
     */
779
    protected function normaliseSQL($sql)
780
    {
781
        return trim(preg_replace('/\s+/m', ' ', $sql));
782
    }
783
784
    /**
785
     * Asserts that two SQL queries are equivalent
786
     *
787
     * @param string $expectedSQL
788
     * @param string $actualSQL
789
     * @param string $message
790
     * @param float|int $delta
791
     * @param integer $maxDepth
792
     * @param boolean $canonicalize
793
     * @param boolean $ignoreCase
794
     */
795
    public function assertSQLEquals(
796
        $expectedSQL,
797
        $actualSQL,
798
        $message = '',
799
        $delta = 0,
800
        $maxDepth = 10,
801
        $canonicalize = false,
802
        $ignoreCase = false
803
    ) {
804
        // Normalise SQL queries to remove patterns of repeating whitespace
805
        $expectedSQL = $this->normaliseSQL($expectedSQL);
806
        $actualSQL = $this->normaliseSQL($actualSQL);
807
808
        $this->assertEquals($expectedSQL, $actualSQL, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
809
    }
810
811
    /**
812
     * Asserts that a SQL query contains a SQL fragment
813
     *
814
     * @param string $needleSQL
815
     * @param string $haystackSQL
816
     * @param string $message
817
     * @param boolean $ignoreCase
818
     * @param boolean $checkForObjectIdentity
819
     */
820
    public function assertSQLContains(
821
        $needleSQL,
822
        $haystackSQL,
823
        $message = '',
824
        $ignoreCase = false,
825
        $checkForObjectIdentity = true
826
    ) {
827
        $needleSQL = $this->normaliseSQL($needleSQL);
828
        $haystackSQL = $this->normaliseSQL($haystackSQL);
829
830
        $this->assertContains($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
831
    }
832
833
    /**
834
     * Asserts that a SQL query contains a SQL fragment
835
     *
836
     * @param string $needleSQL
837
     * @param string $haystackSQL
838
     * @param string $message
839
     * @param boolean $ignoreCase
840
     * @param boolean $checkForObjectIdentity
841
     */
842
    public function assertSQLNotContains(
843
        $needleSQL,
844
        $haystackSQL,
845
        $message = '',
846
        $ignoreCase = false,
847
        $checkForObjectIdentity = true
848
    ) {
849
        $needleSQL = $this->normaliseSQL($needleSQL);
850
        $haystackSQL = $this->normaliseSQL($haystackSQL);
851
852
        $this->assertNotContains($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity);
853
    }
854
855
    /**
856
     * Helper function for the DOS matchers
857
     *
858
     * @param array $item
859
     * @param array $match
860
     * @return bool
861
     */
862
    private function dataObjectArrayMatch($item, $match)
863
    {
864
        foreach ($match as $k => $v) {
865
            if (!array_key_exists($k, $item) || $item[$k] != $v) {
866
                return false;
867
            }
868
        }
869
        return true;
870
    }
871
872
    /**
873
     * Helper function for the DOS matchers
874
     *
875
     * @param SS_List|array $dataObjectSet
876
     * @param array $match
877
     * @return string
878
     */
879
    private function DOSSummaryForMatch($dataObjectSet, $match)
880
    {
881
        $extracted = array();
882
        foreach ($dataObjectSet as $item) {
883
            $extracted[] = array_intersect_key($item->toMap(), $match);
884
        }
885
        return var_export($extracted, true);
886
    }
887
888
    /**
889
     * Start test environment
890
     */
891
    public static function start()
0 ignored issues
show
Coding Style introduced by
start uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
892
    {
893
        if (static::is_running_test()) {
894
            return;
895
        }
896
897
        // Health check
898
        if (InjectorLoader::inst()->countManifests()) {
899
            throw new LogicException("SapphireTest::start() cannot be called within another application");
900
        }
901
        static::set_is_running_test(true);
902
903
        // Mock request
904
        $_SERVER['argv'] = ['vendor/bin/phpunit', '/'];
905
        $request = CLIRequestBuilder::createFromEnvironment();
906
907
        // Test application
908
        $kernel = new TestKernel(BASE_PATH);
909
        $app = new HTTPApplication($kernel);
910
911
        // Custom application
912
        $app->execute($request, function (HTTPRequest $request) {
913
            // Start session and execute
914
            $request->getSession()->init();
915
916
            // Invalidate classname spec since the test manifest will now pull out new subclasses for each internal class
917
            // (e.g. Member will now have various subclasses of DataObjects that implement TestOnly)
918
            DataObject::reset();
919
920
            // Set dummy controller;
921
            $controller = Controller::create();
922
            $controller->setRequest($request);
923
            $controller->pushCurrent();
924
            $controller->doInit();
925
        }, true);
926
927
        // Register state
928
        static::$state = SapphireTestState::singleton();
929
        // Register temp DB holder
930
        static::$tempDB = TempDatabase::create();
931
    }
932
933
    /**
934
     * Reset the testing database's schema, but only if it is active
935
     * @param bool $includeExtraDataObjects If true, the extraDataObjects tables will also be included
936
     * @param bool $forceCreate Force DB to be created if it doesn't exist
937
     */
938
    public static function resetDBSchema($includeExtraDataObjects = false, $forceCreate = false)
939
    {
940
        // Check if DB is active before reset
941
        if (!static::$tempDB->isUsed()) {
942
            if (!$forceCreate) {
943
                return;
944
            }
945
            static::$tempDB->build();
946
        }
947
        $extraDataObjects = $includeExtraDataObjects ? static::getExtraDataObjects() : [];
948
        static::$tempDB->resetDBSchema((array)$extraDataObjects);
949
    }
950
951
    /**
952
     * Create a member and group with the given permission code, and log in with it.
953
     * Returns the member ID.
954
     *
955
     * @param string|array $permCode Either a permission, or list of permissions
956
     * @return int Member ID
957
     */
958
    public function logInWithPermission($permCode = "ADMIN")
959
    {
960
        if (is_array($permCode)) {
961
            $permArray = $permCode;
962
            $permCode = implode('.', $permCode);
963
        } else {
964
            $permArray = array($permCode);
965
        }
966
967
        // Check cached member
968
        if (isset($this->cache_generatedMembers[$permCode])) {
969
            $member = $this->cache_generatedMembers[$permCode];
970
        } else {
971
            // Generate group with these permissions
972
            $group = Group::create();
973
            $group->Title = "$permCode group";
974
            $group->write();
975
976
            // Create each individual permission
977
            foreach ($permArray as $permArrayItem) {
978
                $permission = Permission::create();
979
                $permission->Code = $permArrayItem;
980
                $permission->write();
981
                $group->Permissions()->add($permission);
982
            }
983
984
            $member = Member::get()->filter([
985
                'Email' => "[email protected]",
986
            ])->first();
987
            if (!$member) {
988
                $member = Member::create();
989
            }
990
991
            $member->FirstName = $permCode;
0 ignored issues
show
Documentation introduced by
The property FirstName does not exist on object<SilverStripe\ORM\DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
992
            $member->Surname = "User";
0 ignored issues
show
Documentation introduced by
The property Surname does not exist on object<SilverStripe\ORM\DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
993
            $member->Email = "[email protected]";
0 ignored issues
show
Documentation introduced by
The property Email does not exist on object<SilverStripe\ORM\DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
994
            $member->write();
995
            $group->Members()->add($member);
996
997
            $this->cache_generatedMembers[$permCode] = $member;
998
        }
999
        $this->logInAs($member);
1000
        return $member->ID;
1001
    }
1002
1003
    /**
1004
     * Log in as the given member
1005
     *
1006
     * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in
1007
     */
1008
    public function logInAs($member)
1009
    {
1010
        if (is_numeric($member)) {
1011
            $member = DataObject::get_by_id(Member::class, $member);
1012
        } elseif (!is_object($member)) {
1013
            $member = $this->objFromFixture(Member::class, $member);
1014
        }
1015
        Injector::inst()->get(IdentityStore::class)->logIn($member);
1016
    }
1017
1018
    /**
1019
     * Log out the current user
1020
     */
1021
    public function logOut()
1022
    {
1023
        /** @var IdentityStore $store */
1024
        $store = Injector::inst()->get(IdentityStore::class);
1025
        $store->logOut();
1026
    }
1027
1028
    /**
1029
     * Cache for logInWithPermission()
1030
     */
1031
    protected $cache_generatedMembers = array();
1032
1033
    /**
1034
     * Test against a theme.
1035
     *
1036
     * @param string $themeBaseDir themes directory
1037
     * @param string $theme Theme name
1038
     * @param callable $callback
1039
     * @throws Exception
1040
     */
1041
    protected function useTestTheme($themeBaseDir, $theme, $callback)
1042
    {
1043
        Config::nest();
1044
        if (strpos($themeBaseDir, BASE_PATH) === 0) {
1045
            $themeBaseDir = substr($themeBaseDir, strlen(BASE_PATH));
1046
        }
1047
        SSViewer::config()->update('theme_enabled', true);
1048
        SSViewer::set_themes([$themeBaseDir.'/themes/'.$theme, '$default']);
1049
1050
        try {
1051
            $callback();
1052
        } finally {
1053
            Config::unnest();
1054
        }
1055
    }
1056
1057
    /**
1058
     * Get fixture paths for this test
1059
     *
1060
     * @return array List of paths
1061
     */
1062
    protected function getFixturePaths()
1063
    {
1064
        $fixtureFile = static::get_fixture_file();
1065
        if (empty($fixtureFile)) {
1066
            return [];
1067
        }
1068
1069
        $fixtureFiles = (is_array($fixtureFile)) ? $fixtureFile : [$fixtureFile];
1070
1071
        return array_map(function ($fixtureFilePath) {
1072
            return $this->resolveFixturePath($fixtureFilePath);
1073
        }, $fixtureFiles);
1074
    }
1075
1076
    /**
1077
     * Return all extra objects to scaffold for this test
1078
     * @return array
1079
     */
1080
    public static function getExtraDataObjects()
1081
    {
1082
        return static::$extra_dataobjects;
1083
    }
1084
1085
    /**
1086
     * Get additional controller classes to register routes for
1087
     *
1088
     * @return array
1089
     */
1090
    public static function getExtraControllers()
1091
    {
1092
        return static::$extra_controllers;
1093
    }
1094
1095
    /**
1096
     * Map a fixture path to a physical file
1097
     *
1098
     * @param string $fixtureFilePath
1099
     * @return string
1100
     */
1101
    protected function resolveFixturePath($fixtureFilePath)
1102
    {
1103
        // Support fixture paths relative to the test class, rather than relative to webroot
1104
        // String checking is faster than file_exists() calls.
1105
        $isRelativeToFile
1106
            = (strpos('/', $fixtureFilePath) === false)
1107
            || preg_match('/^(\.){1,2}/', $fixtureFilePath);
1108
1109
        if ($isRelativeToFile) {
1110
            $resolvedPath = realpath($this->getCurrentAbsolutePath() . '/' . $fixtureFilePath);
1111
            if ($resolvedPath) {
1112
                return $resolvedPath;
1113
            }
1114
        }
1115
1116
        // Check if file exists relative to base dir
1117
        $resolvedPath = realpath(Director::baseFolder() . '/' . $fixtureFilePath);
1118
        if ($resolvedPath) {
1119
            return $resolvedPath;
1120
        }
1121
1122
        return $fixtureFilePath;
1123
    }
1124
1125
    protected function setUpRoutes()
1126
    {
1127
        // Get overridden routes
1128
        $rules = $this->getExtraRoutes();
1129
1130
        // Add all other routes
1131
        foreach (Director::config()->uninherited('rules') as $route => $rule) {
1132
            if (!isset($rules[$route])) {
1133
                $rules[$route] = $rule;
1134
            }
1135
        }
1136
1137
        // Add default catch-all rule
1138
        $rules['$Controller//$Action/$ID/$OtherID'] = '*';
1139
1140
        // Add controller-name auto-routing
1141
        Director::config()->set('rules', $rules);
1142
    }
1143
1144
    /**
1145
     * Get extra routes to merge into Director.rules
1146
     *
1147
     * @return array
1148
     */
1149
    protected function getExtraRoutes()
1150
    {
1151
        $rules = [];
1152
        foreach ($this->getExtraControllers() as $class) {
1153
            $controllerInst = Controller::singleton($class);
1154
            $link = Director::makeRelative($controllerInst->Link());
1155
            $route = rtrim($link, '/') . '//$Action/$ID/$OtherID';
1156
            $rules[$route] = $class;
1157
        }
1158
        return $rules;
1159
    }
1160
}
1161