Issues (2882)

src/Dev/FunctionalTest.php (6 issues)

1
<?php
2
3
namespace SilverStripe\Dev;
4
5
use SilverStripe\Control\Director;
6
use SilverStripe\Control\Session;
7
use SilverStripe\Control\HTTPResponse;
8
use SilverStripe\Core\Config\Config;
9
use SilverStripe\Security\BasicAuth;
10
use SilverStripe\Security\SecurityToken;
11
use SilverStripe\View\SSViewer;
12
use SimpleXMLElement;
13
14
/**
15
 * SilverStripe-specific testing object designed to support functional testing of your web app.  It simulates get/post
16
 * requests, form submission, and can validate resulting HTML, looking up content by CSS selector.
17
 *
18
 * The example below shows how it works.
19
 *
20
 * <code>
21
 *   public function testMyForm() {
22
 *   // Visit a URL
23
 *   $this->get("your/url");
24
 *
25
 *   // Submit a form on the page that you get in response
26
 *   $this->submitForm("MyForm_ID", "action_dologin", array("Email" => "invalid email ^&*&^"));
27
 *
28
 *   // Validate the content that is returned
29
 *   $this->assertExactMatchBySelector("#MyForm_ID p.error", array("That email address is invalid."));
30
 *  }
31
 * </code>
32
 */
33
abstract class FunctionalTest extends SapphireTest implements TestOnly
34
{
35
    /**
36
     * Set this to true on your sub-class to disable the use of themes in this test.
37
     * This can be handy for functional testing of modules without having to worry about whether a user has changed
38
     * behaviour by replacing the theme.
39
     *
40
     * @var bool
41
     */
42
    protected static $disable_themes = false;
43
44
    /**
45
     * Set this to true on your sub-class to use the draft site by default for every test in this class.
46
     *
47
     * @deprecated 4.2.0:5.0.0 Use ?stage=Stage in your ->get() querystring requests instead
48
     * @var bool
49
     */
50
    protected static $use_draft_site = false;
51
52
    /**
53
     * @var TestSession
54
     */
55
    protected $mainSession = null;
56
57
    /**
58
     * CSSContentParser for the most recently requested page.
59
     *
60
     * @var CSSContentParser
61
     */
62
    protected $cssParser = null;
63
64
    /**
65
     * If this is true, then 30x Location headers will be automatically followed.
66
     * If not, then you will have to manaully call $this->mainSession->followRedirection() to follow them.
67
     * However, this will let you inspect the intermediary headers
68
     *
69
     * @var bool
70
     */
71
    protected $autoFollowRedirection = true;
72
73
    /**
74
     * Returns the {@link Session} object for this test
75
     *
76
     * @return Session
77
     */
78
    public function session() : Session
79
    {
80
        return $this->mainSession->session();
81
    }
82
83
    protected function setUp() : void
84
    {
85
        parent::setUp();
86
87
        // Skip calling FunctionalTest directly.
88
        if (static::class == __CLASS__) {
0 ignored issues
show
The condition static::class == __CLASS__ is always true.
Loading history...
89
            $this->markTestSkipped(sprintf('Skipping %s ', static::class));
90
        }
91
92
        $this->mainSession = new TestSession();
93
94
        // Disable theme, if necessary
95
        if (static::get_disable_themes()) {
96
            SSViewer::config()->update('theme_enabled', false);
97
        }
98
99
        // Flush user
100
        $this->logOut();
101
102
        // Switch to draft site, if necessary
103
        // If you rely on this you should be crafting stage-specific urls instead though.
104
        if (static::get_use_draft_site()) {
105
            $this->useDraftSite();
106
        }
107
108
        // Unprotect the site, tests are running with the assumption it's off. They will enable it on a case-by-case
109
        // basis.
110
        BasicAuth::protect_entire_site(false);
111
112
        SecurityToken::disable();
113
    }
114
115
    protected function tearDown() : void
116
    {
117
        SecurityToken::enable();
118
        unset($this->mainSession);
119
        parent::tearDown();
120
    }
121
122
    /**
123
     * Run a test while mocking the base url with the provided value
124
     * @param string $url The base URL to use for this test
125
     * @param callable $callback The test to run
126
     */
127
    protected function withBaseURL(string $url, callable $callback)
128
    {
129
        $oldBase = Config::inst()->get(Director::class, 'alternate_base_url');
130
        Config::modify()->set(Director::class, 'alternate_base_url', $url);
131
        $callback($this);
132
        Config::modify()->set(Director::class, 'alternate_base_url', $oldBase);
133
    }
134
135
    /**
136
     * Run a test while mocking the base folder with the provided value
137
     * @param string $folder The base folder to use for this test
138
     * @param callable $callback The test to run
139
     */
140
    protected function withBaseFolder(string $folder, callable $callback)
141
    {
142
        $oldFolder = Config::inst()->get(Director::class, 'alternate_base_folder');
143
        Config::modify()->set(Director::class, 'alternate_base_folder', $folder);
144
        $callback($this);
145
        Config::modify()->set(Director::class, 'alternate_base_folder', $oldFolder);
146
    }
147
148
    /**
149
     * Submit a get request
150
     * @uses Director::test()
151
     *
152
     * @param string $url
153
     * @param Session $session
154
     * @param array $headers
155
     * @param array $cookies
156
     * @return HTTPResponse
157
     */
158
    public function get(
159
        string $url,
160
        Session $session = null,
161
        array $headers = null,
162
        array $cookies = null
163
    ) : HTTPResponse {
164
        $this->cssParser = null;
165
        $response = $this->mainSession->get($url, $session, $headers, $cookies);
166
        if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
167
            $response = $this->mainSession->followRedirection();
168
        }
169
        return $response;
170
    }
171
172
    /**
173
     * Submit a post request
174
     *
175
     * @uses Director::test()
176
     * @param string $url
177
     * @param array $data
178
     * @param array $headers
179
     * @param Session $session
180
     * @param string $body
181
     * @param array $cookies
182
     * @return HTTPResponse
183
     */
184
    public function post($url, $data, $headers = null, $session = null, $body = null, $cookies = null)
185
    {
186
        $this->cssParser = null;
187
        $response = $this->mainSession->post($url, $data, $headers, $session, $body, $cookies);
188
        if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
189
            $response = $this->mainSession->followRedirection();
190
        }
191
        return $response;
192
    }
193
194
    /**
195
     * Submit the form with the given HTML ID, filling it out with the given data.
196
     * Acts on the most recent response.
197
     *
198
     * Any data parameters have to be present in the form, with exact form field name
199
     * and values, otherwise they are removed from the submission.
200
     *
201
     * Caution: Parameter names have to be formatted
202
     * as they are in the form submission, not as they are interpreted by PHP.
203
     * Wrong: array('mycheckboxvalues' => array(1 => 'one', 2 => 'two'))
204
     * Right: array('mycheckboxvalues[1]' => 'one', 'mycheckboxvalues[2]' => 'two')
205
     *
206
     * @see http://www.simpletest.org/en/form_testing_documentation.html
207
     *
208
     * @param string $formID HTML 'id' attribute of a form (loaded through a previous response)
209
     * @param string $button HTML 'name' attribute of the button (NOT the 'id' attribute)
210
     * @param array $data Map of GET/POST data.
211
     * @return HTTPResponse
212
     */
213
    public function submitForm($formID, $button = null, $data = array())
214
    {
215
        $this->cssParser = null;
216
        $response = $this->mainSession->submitForm($formID, $button, $data);
217
        if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
218
            $response = $this->mainSession->followRedirection();
219
        }
220
        return $response;
221
    }
222
223
    /**
224
     * Return the most recent content
225
     *
226
     * @return string
227
     */
228
    public function content()
229
    {
230
        return $this->mainSession->lastContent();
231
    }
232
233
    /**
234
     * Find an attribute in a SimpleXMLElement object by name.
235
     * @param SimpleXMLElement $object
236
     * @param string $attribute Name of attribute to find
237
     * @return SimpleXMLElement object of the attribute
238
     */
239
    public function findAttribute($object, $attribute)
240
    {
241
        $found = false;
242
        foreach ($object->attributes() as $a => $b) {
243
            if ($a == $attribute) {
244
                $found = $b;
245
            }
246
        }
247
        return $found;
248
    }
249
250
    /**
251
     * Return a CSSContentParser for the most recent content.
252
     *
253
     * @return CSSContentParser
254
     */
255
    public function cssParser()
256
    {
257
        if (!$this->cssParser) {
258
            $this->cssParser = new CSSContentParser($this->mainSession->lastContent());
259
        }
260
        return $this->cssParser;
261
    }
262
263
    /**
264
     * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
265
     * The given CSS selector will be applied to the HTML of the most recent page.  The content of every matching tag
266
     * will be examined. The assertion fails if one of the expectedMatches fails to appear.
267
     *
268
     * Note: &nbsp; characters are stripped from the content; make sure that your assertions take this into account.
269
     *
270
     * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
271
     * @param array|string $expectedMatches The content of at least one of the matched tags
272
     * @param string $message
273
     * @throws \PHPUnit\Framework\AssertionFailedError
274
     */
275
    public function assertPartialMatchBySelector($selector, $expectedMatches, $message = null)
276
    {
277
        if (is_string($expectedMatches)) {
278
            $expectedMatches = array($expectedMatches);
279
        }
280
281
        $items = $this->cssParser()->getBySelector($selector);
282
283
        $actuals = array();
284
        if ($items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $items of type SimpleXMLElement[] 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...
285
            foreach ($items as $item) {
286
                $actuals[trim(preg_replace('/\s+/', ' ', (string)$item))] = true;
287
            }
288
        }
289
290
        $message = $message ?:
291
        "Failed asserting the CSS selector '$selector' has a partial match to the expected elements:\n'"
292
            . implode("'\n'", $expectedMatches) . "'\n\n"
293
            . "Instead the following elements were found:\n'" . implode("'\n'", array_keys($actuals)) . "'";
294
295
        foreach ($expectedMatches as $match) {
296
            $this->assertTrue(isset($actuals[$match]), $message);
297
        }
298
    }
299
300
    /**
301
     * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
302
     * The given CSS selector will be applied to the HTML of the most recent page.  The full HTML of every matching tag
303
     * will be examined. The assertion fails if one of the expectedMatches fails to appear.
304
     *
305
     * Note: &nbsp; characters are stripped from the content; make sure that your assertions take this into account.
306
     *
307
     * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
308
     * @param array|string $expectedMatches The content of *all* matching tags as an array
309
     * @param string $message
310
     * @throws \PHPUnit\Framework\AssertionFailedError
311
     */
312
    public function assertExactMatchBySelector($selector, $expectedMatches, $message = null)
313
    {
314
        if (is_string($expectedMatches)) {
315
            $expectedMatches = array($expectedMatches);
316
        }
317
318
        $items = $this->cssParser()->getBySelector($selector);
319
320
        $actuals = array();
321
        if ($items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $items of type SimpleXMLElement[] 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...
322
            foreach ($items as $item) {
323
                $actuals[] = trim(preg_replace('/\s+/', ' ', (string)$item));
324
            }
325
        }
326
327
        $message = $message ?:
328
                "Failed asserting the CSS selector '$selector' has an exact match to the expected elements:\n'"
329
                . implode("'\n'", $expectedMatches) . "'\n\n"
330
            . "Instead the following elements were found:\n'" . implode("'\n'", $actuals) . "'";
331
332
        $this->assertTrue($expectedMatches == $actuals, $message);
333
    }
334
335
    /**
336
     * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
337
     * The given CSS selector will be applied to the HTML of the most recent page.  The content of every matching tag
338
     * will be examined. The assertion fails if one of the expectedMatches fails to appear.
339
     *
340
     * Note: &nbsp; characters are stripped from the content; make sure that your assertions take this into account.
341
     *
342
     * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
343
     * @param array|string $expectedMatches The content of at least one of the matched tags
344
     * @param string $message
345
     * @throws \PHPUnit\Framework\AssertionFailedError
346
     */
347
    public function assertPartialHTMLMatchBySelector($selector, $expectedMatches, $message = null)
348
    {
349
        if (is_string($expectedMatches)) {
350
            $expectedMatches = array($expectedMatches);
351
        }
352
353
        $items = $this->cssParser()->getBySelector($selector);
354
355
        $actuals = array();
356
        if ($items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $items of type SimpleXMLElement[] 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...
357
            /** @var SimpleXMLElement $item */
358
            foreach ($items as $item) {
359
                $actuals[$item->asXML()] = true;
360
            }
361
        }
362
363
        $message = $message ?:
364
                "Failed asserting the CSS selector '$selector' has a partial match to the expected elements:\n'"
365
                . implode("'\n'", $expectedMatches) . "'\n\n"
366
            . "Instead the following elements were found:\n'" . implode("'\n'", array_keys($actuals)) . "'";
367
368
        foreach ($expectedMatches as $match) {
369
            $this->assertTrue(isset($actuals[$match]), $message);
370
        }
371
    }
372
373
    /**
374
     * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
375
     * The given CSS selector will be applied to the HTML of the most recent page.  The full HTML of every matching tag
376
     * will be examined. The assertion fails if one of the expectedMatches fails to appear.
377
     *
378
     * Note: &nbsp; characters are stripped from the content; make sure that your assertions take this into account.
379
     *
380
     * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
381
     * @param array|string $expectedMatches The content of *all* matched tags as an array
382
     * @param string $message
383
     * @throws \PHPUnit\Framework\AssertionFailedError
384
     */
385
    public function assertExactHTMLMatchBySelector($selector, $expectedMatches, $message = null)
386
    {
387
        $items = $this->cssParser()->getBySelector($selector);
388
389
        $actuals = array();
390
        if ($items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $items of type SimpleXMLElement[] 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...
391
            /** @var SimpleXMLElement $item */
392
            foreach ($items as $item) {
393
                $actuals[] = $item->asXML();
394
            }
395
        }
396
397
        $message = $message ?:
398
            "Failed asserting the CSS selector '$selector' has an exact match to the expected elements:\n'"
399
            . implode("'\n'", $expectedMatches) . "'\n\n"
0 ignored issues
show
It seems like $expectedMatches can also be of type string; however, parameter $pieces of implode() does only seem to accept array, 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

399
            . implode("'\n'", /** @scrutinizer ignore-type */ $expectedMatches) . "'\n\n"
Loading history...
400
            . "Instead the following elements were found:\n'" . implode("'\n'", $actuals) . "'";
401
402
        $this->assertTrue($expectedMatches == $actuals, $message);
403
    }
404
405
    /**
406
     * Use the draft (stage) site for testing.
407
     * This is helpful if you're not testing publication functionality and don't want "stage management" cluttering
408
     * your test.
409
     *
410
     * @deprecated 4.2.0:5.0.0 Use ?stage=Stage querystring arguments instead of useDraftSite
411
     * @param bool $enabled toggle the use of the draft site
412
     */
413
    public function useDraftSite($enabled = true)
414
    {
415
        Deprecation::notice('5.0', 'Use ?stage=Stage querystring arguments instead of useDraftSite');
416
        if ($enabled) {
417
            $this->session()->set('readingMode', 'Stage.Stage');
418
            $this->session()->set('unsecuredDraftSite', true);
419
        } else {
420
            $this->session()->clear('readingMode');
421
            $this->session()->clear('unsecuredDraftSite');
422
        }
423
    }
424
425
    /**
426
     * @return bool
427
     */
428
    public static function get_disable_themes()
429
    {
430
        return static::$disable_themes;
431
    }
432
433
    /**
434
     * @deprecated 4.2.0:5.0.0 Use ?stage=Stage in your querystring arguments instead
435
     * @return bool
436
     */
437
    public static function get_use_draft_site()
438
    {
439
        return static::$use_draft_site;
440
    }
441
}
442