Skip to content

Commit b8187a3

Browse files
malbertsclaude
andcommitted
Bring the test suite up to MediaWiki's PHPUnit
The suite could not run. Its one test class extended PHPUnit_Framework_TestCase, removed in PHPUnit 6, and pulled the production classes in with relative require paths; tests/phpunit/phpunit.xml declared the PHPUnit 4.5 schema and a bootstrap that only set a timezone. The classes now extend MediaWikiIntegrationTestCase and load through the extension's own autoloader, and phpunit.xml.dist moves to the extension root on the PHPUnit 9.6 schema, so core's entry point picks the suite up: composer phpunit:entrypoint -- extensions/Bugzilla/tests/phpunit The existing prepare_options and rebase_fields cases are kept. What is new covers the two behaviours that are invisible from a rendered page: - A query that failed must not leave an empty result in the cache. Until it expires, every later reader would be shown 'No results.' for a query that errored, which is why this one is worth pinning: the page looks correct. - display="number" must report an error rather than counting a result that is not there, and errors must be escaped rather than rendered as markup. Also pinned: the fields a query fetches are a superset of the fields it displays, which is easy to break while touching either. The dev dependencies were an abandoned package name for the linter and a 2016 codesniffer, neither installable next to a current MediaWiki. Both now track the versions core uses. No runtime dependency is added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9b606d2 commit b8187a3

8 files changed

Lines changed: 275 additions & 55 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ and b) display those columns.
123123

124124
![Screenshot of the above](http://i.imgur.com/p3u7r.png "Screenshot of the above")
125125

126+
Tests
127+
================================
128+
129+
Unit and integration tests run through MediaWiki core's PHPUnit entry point,
130+
from the MediaWiki install directory:
131+
132+
```
133+
composer phpunit:entrypoint -- extensions/Bugzilla/tests/phpunit
134+
```
135+
136+
`composer test` at the extension root runs the linter and code sniffer only.
137+
126138
Limitations
127139
================================
128140

composer.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
{
22
"require-dev": {
3-
"jakub-onderka/php-parallel-lint": "0.9.2",
4-
"mediawiki/mediawiki-codesniffer": "0.7.2"
3+
"php-parallel-lint/php-parallel-lint": "^1.4",
4+
"mediawiki/mediawiki-codesniffer": "^45.0"
55
},
66
"scripts": {
77
"fix": "phpcbf",
88
"test": [
99
"parallel-lint . --exclude vendor",
1010
"phpcs -p -s"
1111
]
12+
},
13+
"config": {
14+
"allow-plugins": {
15+
"dealerdirect/phpcodesniffer-composer-installer": true
16+
}
1217
}
1318
}

phpunit.xml.dist

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
5+
colors="true"
6+
beStrictAboutTestsThatDoNotTestAnything="true"
7+
convertErrorsToExceptions="true"
8+
convertNoticesToExceptions="true"
9+
convertWarningsToExceptions="true">
10+
<testsuites>
11+
<testsuite name="Bugzilla">
12+
<directory>tests/phpunit</directory>
13+
</testsuite>
14+
</testsuites>
15+
<coverage>
16+
<include>
17+
<directory suffix=".php">.</directory>
18+
</include>
19+
<exclude>
20+
<directory>tests</directory>
21+
<directory>vendor</directory>
22+
<directory>web</directory>
23+
</exclude>
24+
</coverage>
25+
</phpunit>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
# This Source Code Form is subject to the terms of the Mozilla Public
3+
# License, v. 2.0. If a copy of the MPL was not distributed with this
4+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
5+
6+
/**
7+
* @covers \BugzillaNumber
8+
* @covers \BugzillaOutput
9+
*/
10+
class BugzillaOutputTest extends MediaWikiIntegrationTestCase
11+
{
12+
13+
protected function setUp(): void
14+
{
15+
parent::setUp();
16+
17+
$this->setMwGlobals([
18+
'wgBugzillaMethod' => 'REST',
19+
'wgBugzillaRESTURL' => 'https://bugzilla.example.org/rest',
20+
'wgBugzillaURL' => 'https://bugzilla.example.org',
21+
'wgBugzillaDefaultFields' => ['id', 'summary', 'priority', 'status'],
22+
]);
23+
}
24+
25+
public function testNumberDisplayReportsAnErrorRatherThanCountingAnAbsentResult()
26+
{
27+
$output = Bugzilla::create(['display' => 'number'], '{"product": "Bugzilla"}', 'title');
28+
$output->query->error = 'Bugzilla is unreachable';
29+
30+
$this->assertStringContainsString('Bugzilla is unreachable', $output->render());
31+
}
32+
33+
public function testNumberDisplayCountsTheBugsReturned()
34+
{
35+
$output = Bugzilla::create(['display' => 'number'], '{"product": "Bugzilla"}', 'title');
36+
$output->query->data = ['bugs' => [['id' => 1], ['id' => 2], ['id' => 3]]];
37+
38+
$this->assertSame('<span>3</span>', $output->render());
39+
}
40+
41+
public function testAnErrorIsEscapedRatherThanRenderedAsMarkup()
42+
{
43+
$output = Bugzilla::create(['display' => 'table'], '{"product": "Bugzilla"}', 'title');
44+
$output->query->error = '<script>alert(1)</script>';
45+
46+
$rendered = $output->render();
47+
48+
$this->assertStringNotContainsString('<script>', $rendered);
49+
$this->assertStringContainsString('&lt;script&gt;', $rendered);
50+
}
51+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
# This Source Code Form is subject to the terms of the Mozilla Public
3+
# License, v. 2.0. If a copy of the MPL was not distributed with this
4+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
5+
6+
/**
7+
* The cache sits between a failed request and the reader, and its effects
8+
* outlive the request that caused them, so they are invisible from the
9+
* rendered page until the timeout expires.
10+
*
11+
* @covers \BugzillaBaseQuery
12+
*/
13+
class BugzillaQueryCachingTest extends MediaWikiIntegrationTestCase
14+
{
15+
16+
private const OPTIONS = '{"product": "Bugzilla"}';
17+
18+
private const BUGS = ['bugs' => [['id' => 4001, 'summary' => 'A bug', 'status' => 'NEW']]];
19+
20+
protected function setUp(): void
21+
{
22+
parent::setUp();
23+
24+
$this->setMwGlobals([
25+
'wgMainCacheType' => CACHE_HASH,
26+
'wgBugzillaCacheTimeOut' => 5,
27+
'wgBugzillaDefaultFields' => ['id', 'summary', 'priority', 'status'],
28+
]);
29+
}
30+
31+
public function testARepeatedQueryIsAnsweredFromTheCache()
32+
{
33+
$this->newQueryReturning(self::BUGS)->fetch();
34+
35+
$second = $this->newQueryFailingWith('Bugzilla is unreachable');
36+
$second->fetch();
37+
38+
$this->assertSame(self::BUGS, $second->data);
39+
$this->assertFalse($second->error);
40+
}
41+
42+
public function testAFailedQueryDoesNotLeaveAnEmptyResultBehindForTheNextReader()
43+
{
44+
$this->newQueryFailingWith('Bugzilla is unreachable')->fetch();
45+
46+
$second = $this->newQueryReturning(self::BUGS);
47+
$second->fetch();
48+
49+
$this->assertSame(self::BUGS, $second->data);
50+
}
51+
52+
public function testAFailedQueryReportsItsError()
53+
{
54+
$query = $this->newQueryFailingWith('Bugzilla is unreachable');
55+
56+
$query->fetch();
57+
58+
$this->assertSame('Bugzilla is unreachable', $query->error);
59+
$this->assertSame([], $query->data);
60+
}
61+
62+
private function newQueryReturning(array $data): BugzillaFakeQuery
63+
{
64+
return new BugzillaFakeQuery('bug', self::OPTIONS, 'title', $data, false);
65+
}
66+
67+
private function newQueryFailingWith(string $error): BugzillaFakeQuery
68+
{
69+
return new BugzillaFakeQuery('bug', self::OPTIONS, 'title', [], $error);
70+
}
71+
}
72+
73+
/**
74+
* Stands in for the HTTP round trip so the caching around it can be observed.
75+
*/
76+
class BugzillaFakeQuery extends BugzillaBaseQuery
77+
{
78+
79+
private $response;
80+
81+
private $failure;
82+
83+
public function __construct($type, $options, $title, array $response, $failure)
84+
{
85+
parent::__construct($type, $options, $title);
86+
87+
$this->response = $response;
88+
$this->failure = $failure;
89+
}
90+
91+
public function _fetch_by_options()
92+
{
93+
if ($this->failure) {
94+
$this->error = $this->failure;
95+
return;
96+
}
97+
98+
$this->data = $this->response;
99+
}
100+
}
Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,156 @@
11
<?php
2+
# This Source Code Form is subject to the terms of the Mozilla Public
3+
# License, v. 2.0. If a copy of the MPL was not distributed with this
4+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
25

3-
require '../../Bugzilla.class.php';
4-
require '../../BugzillaQuery.class.php';
5-
6-
class BugzillaQueryTest extends PHPUnit_Framework_TestCase
6+
/**
7+
* @covers \BugzillaQuery
8+
* @covers \BugzillaBaseQuery
9+
* @covers \BugzillaRESTQuery
10+
*/
11+
class BugzillaQueryTest extends MediaWikiIntegrationTestCase
712
{
13+
14+
protected function setUp(): void
15+
{
16+
parent::setUp();
17+
18+
$this->setMwGlobals([
19+
'wgBugzillaMethod' => 'REST',
20+
'wgBugzillaRESTURL' => 'https://bugzilla.example.org/rest',
21+
'wgBugzillaURL' => 'https://bugzilla.example.org',
22+
'wgBugzillaDefaultFields' => ['id', 'summary', 'priority', 'status'],
23+
]);
24+
}
25+
826
/**
927
* @dataProvider prepareOptionsProvider
1028
*
1129
* @param string $json JSON options structure defined in <bugzilla /> element.
1230
* @param array $default default fields to include in query
1331
* @param array $expected resulting options array
14-
*/
32+
*/
1533
public function testPrepareOptions($json, $default, $expected)
1634
{
1735
$q = BugzillaQuery::create('bug', $json, 'title');
1836
$this->assertEquals($expected, $q->prepare_options($json, $default));
1937
}
2038

21-
public function prepareOptionsProvider()
39+
public static function prepareOptionsProvider()
2240
{
2341
$default_includes = ['incA', 'incB'];
2442
return [
25-
[
43+
'no query at all falls back to the default fields' => [
2644
'',
2745
[],
2846
['include_fields' => []]
2947
],
30-
[
48+
'an empty JSON object falls back to the default fields' => [
3149
" { \n } ",
3250
['default'],
3351
['include_fields' => ['default']]
3452
],
35-
[
53+
'other options are kept alongside the default fields' => [
3654
'{"other":"options"}',
3755
$default_includes,
3856
[
3957
'include_fields' => $default_includes,
4058
'other' => 'options'
4159
]
4260
],
43-
[
61+
'a single requested field replaces the defaults' => [
4462
'{"include_fields": ["C"]}',
4563
$default_includes,
4664
[
4765
'include_fields' => ['C']
4866
]
4967
],
50-
[
68+
'a JSON array of fields is used as given' => [
5169
'{"include_fields": ["json", "array"]}',
5270
$default_includes,
5371
[
5472
'include_fields' => ['json', 'array']
5573
]
5674
],
57-
[
75+
'a comma separated string of fields is split' => [
5876
'{"include_fields": "json,string"}',
5977
$default_includes,
6078
[
6179
'include_fields' => ['json', 'string']
6280
]
6381
],
64-
[
82+
'invalid JSON produces no options' => [
6583
'invalid JSON',
6684
$default_includes,
6785
null
6886
],
6987
];
7088
}
7189

72-
public function testPrepareOptionsError()
90+
public function testInvalidJsonOptionsAreReportedAsAnError()
7391
{
7492
$q = BugzillaQuery::create('bug', 'invalid JSON', 'title');
75-
$this->assertEquals(null, $q->prepare_options('invalid JSON', ['A', 'B']));
76-
$this->assertEquals('Query options must be valid JSON.', $q->error);
93+
94+
$this->assertSame('Query options must be valid JSON.', $q->error);
7795
}
7896

7997
/**
8098
* @dataProvider rebaseFieldsProvider
81-
*/
99+
*/
82100
public function testRebaseFields($request, $synthetic, $expected)
83101
{
84102
$q = BugzillaQuery::create('bug', '{}', 'title');
103+
85104
$this->assertEquals($expected, $q->rebase_fields($request, $synthetic));
86105
}
87106

88-
public function rebaseFieldsProvider()
107+
public static function rebaseFieldsProvider()
89108
{
90109
return [
91-
[ ['A', 'B', 'C'], ['A', 'B'], ['A', 'B', 'C'] ],
92-
[ ['A'], ['A', 'B'], ['A', 'B'] ],
93-
[ ['C'], ['B', 'A'], ['A', 'B', 'C'] ],
110+
'already a superset of the synthetic fields' => [ ['A', 'B', 'C'], ['A', 'B'], ['A', 'B', 'C'] ],
111+
'missing synthetic fields are added' => [ ['A'], ['A', 'B'], ['A', 'B'] ],
112+
'the result is sorted' => [ ['C'], ['B', 'A'], ['A', 'B', 'C'] ],
94113
];
95114
}
96115

116+
public function testFieldsNeededOnlyForRenderingAreFetchedButNotDisplayed()
117+
{
118+
$q = BugzillaQuery::create('bug', '{"include_fields": ["summary"]}', 'title');
119+
120+
$this->assertSame(
121+
['summary'],
122+
$q->options['include_fields'],
123+
'the columns are the ones the wiki page asked for'
124+
);
125+
$this->assertSame(
126+
['id', 'priority', 'status', 'summary'],
127+
$q->rebased_options()['include_fields'],
128+
'the request also carries the fields the templates need'
129+
);
130+
}
131+
132+
public function testTheFullQueryUrlRepeatsEachValueOfAnArrayOption()
133+
{
134+
$q = BugzillaQuery::create(
135+
'bug',
136+
'{"product": "Bugzilla", "include_fields": ["id", "summary"]}',
137+
'title'
138+
);
139+
140+
$this->assertSame(
141+
'https://bugzilla.example.org/buglist.cgi'
142+
. '?product=Bugzilla&include_fields=id&include_fields=summary',
143+
$q->full_query_url()
144+
);
145+
}
146+
147+
public function testTheFullQueryUrlEscapesValues()
148+
{
149+
$q = BugzillaQuery::create('bug', '{"whiteboard": "[needs triage]"}', 'title');
150+
151+
$this->assertStringContainsString(
152+
'whiteboard=%5Bneeds+triage%5D',
153+
$q->full_query_url()
154+
);
155+
}
97156
}

0 commit comments

Comments
 (0)