Merge "Quoted attributes don't need to be followed by a space"
[lhc/web/wiklou.git] / tests / phpunit / includes / Revision / RevisionRendererTest.php
1 <?php
2
3 namespace MediaWiki\Tests\Revision;
4
5 use CommentStoreComment;
6 use Content;
7 use Language;
8 use LogicException;
9 use MediaWiki\Revision\MutableRevisionRecord;
10 use MediaWiki\Revision\RevisionRecord;
11 use MediaWiki\Revision\RevisionRenderer;
12 use MediaWiki\Revision\SlotRecord;
13 use MediaWikiTestCase;
14 use MediaWiki\User\UserIdentityValue;
15 use ParserOptions;
16 use ParserOutput;
17 use PHPUnit\Framework\MockObject\MockObject;
18 use Title;
19 use User;
20 use Wikimedia\Rdbms\IDatabase;
21 use Wikimedia\Rdbms\ILoadBalancer;
22 use WikitextContent;
23
24 /**
25 * @covers \MediaWiki\Revision\RevisionRenderer
26 */
27 class RevisionRendererTest extends MediaWikiTestCase {
28
29 /**
30 * @param $articleId
31 * @param $revisionId
32 * @return Title
33 */
34 private function getMockTitle( $articleId, $revisionId ) {
35 /** @var Title|MockObject $mock */
36 $mock = $this->getMockBuilder( Title::class )
37 ->disableOriginalConstructor()
38 ->getMock();
39 $mock->expects( $this->any() )
40 ->method( 'getNamespace' )
41 ->will( $this->returnValue( NS_MAIN ) );
42 $mock->expects( $this->any() )
43 ->method( 'getText' )
44 ->will( $this->returnValue( __CLASS__ ) );
45 $mock->expects( $this->any() )
46 ->method( 'getPrefixedText' )
47 ->will( $this->returnValue( __CLASS__ ) );
48 $mock->expects( $this->any() )
49 ->method( 'getDBkey' )
50 ->will( $this->returnValue( __CLASS__ ) );
51 $mock->expects( $this->any() )
52 ->method( 'getArticleID' )
53 ->will( $this->returnValue( $articleId ) );
54 $mock->expects( $this->any() )
55 ->method( 'getLatestRevId' )
56 ->will( $this->returnValue( $revisionId ) );
57 $mock->expects( $this->any() )
58 ->method( 'getContentModel' )
59 ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) );
60 $mock->expects( $this->any() )
61 ->method( 'getPageLanguage' )
62 ->will( $this->returnValue( Language::factory( 'en' ) ) );
63 $mock->expects( $this->any() )
64 ->method( 'isContentPage' )
65 ->will( $this->returnValue( true ) );
66 $mock->expects( $this->any() )
67 ->method( 'equals' )
68 ->willReturnCallback(
69 function ( Title $other ) use ( $mock ) {
70 return $mock->getArticleID() === $other->getArticleID();
71 }
72 );
73 $mock->expects( $this->any() )
74 ->method( 'userCan' )
75 ->willReturnCallback(
76 function ( $perm, User $user ) use ( $mock ) {
77 return $user->isAllowed( $perm );
78 }
79 );
80
81 return $mock;
82 }
83
84 /**
85 * @param int $maxRev
86 * @param int $linkCount
87 *
88 * @return IDatabase
89 */
90 private function getMockDatabaseConnection( $maxRev = 100, $linkCount = 0 ) {
91 /** @var IDatabase|MockObject $db */
92 $db = $this->getMock( IDatabase::class );
93 $db->method( 'selectField' )
94 ->willReturnCallback(
95 function ( $table, $fields, $cond ) use ( $maxRev, $linkCount ) {
96 return $this->selectFieldCallback(
97 $table,
98 $fields,
99 $cond,
100 $maxRev,
101 $linkCount
102 );
103 }
104 );
105
106 return $db;
107 }
108
109 /**
110 * @return RevisionRenderer
111 */
112 private function newRevisionRenderer( $maxRev = 100, $useMaster = false ) {
113 $dbIndex = $useMaster ? DB_MASTER : DB_REPLICA;
114
115 $db = $this->getMockDatabaseConnection( $maxRev );
116
117 /** @var ILoadBalancer|MockObject $lb */
118 $lb = $this->getMock( ILoadBalancer::class );
119 $lb->method( 'getConnection' )
120 ->with( $dbIndex )
121 ->willReturn( $db );
122 $lb->method( 'getConnectionRef' )
123 ->with( $dbIndex )
124 ->willReturn( $db );
125 $lb->method( 'getLazyConnectionRef' )
126 ->with( $dbIndex )
127 ->willReturn( $db );
128
129 return new RevisionRenderer( $lb );
130 }
131
132 private function selectFieldCallback( $table, $fields, $cond, $maxRev ) {
133 if ( [ $table, $fields, $cond ] === [ 'revision', 'MAX(rev_id)', [] ] ) {
134 return $maxRev;
135 }
136
137 $this->fail( 'Unexpected call to selectField' );
138 throw new LogicException( 'Ooops' ); // Can't happen, make analyzer happy
139 }
140
141 public function testGetRenderedRevision_new() {
142 $renderer = $this->newRevisionRenderer( 100 );
143 $title = $this->getMockTitle( 7, 21 );
144
145 $rev = new MutableRevisionRecord( $title );
146 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
147 $rev->setTimestamp( '20180101000003' );
148 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
149
150 $text = "";
151 $text .= "* page:{{PAGENAME}}\n";
152 $text .= "* rev:{{REVISIONID}}\n";
153 $text .= "* user:{{REVISIONUSER}}\n";
154 $text .= "* time:{{REVISIONTIMESTAMP}}\n";
155 $text .= "* [[Link It]]\n";
156
157 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
158
159 $options = ParserOptions::newCanonical( 'canonical' );
160 $rr = $renderer->getRenderedRevision( $rev, $options );
161
162 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
163
164 $this->assertSame( $rev, $rr->getRevision() );
165 $this->assertSame( $options, $rr->getOptions() );
166
167 $html = $rr->getRevisionParserOutput()->getText();
168
169 $this->assertContains( 'page:' . __CLASS__, $html );
170 $this->assertContains( 'rev:101', $html ); // from speculativeRevIdCallback
171 $this->assertContains( 'user:Frank', $html );
172 $this->assertContains( 'time:20180101000003', $html );
173
174 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
175 }
176
177 public function testGetRenderedRevision_current() {
178 $renderer = $this->newRevisionRenderer( 100 );
179 $title = $this->getMockTitle( 7, 21 );
180
181 $rev = new MutableRevisionRecord( $title );
182 $rev->setId( 21 ); // current!
183 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
184 $rev->setTimestamp( '20180101000003' );
185 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
186
187 $text = "";
188 $text .= "* page:{{PAGENAME}}\n";
189 $text .= "* rev:{{REVISIONID}}\n";
190 $text .= "* user:{{REVISIONUSER}}\n";
191 $text .= "* time:{{REVISIONTIMESTAMP}}\n";
192
193 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
194
195 $options = ParserOptions::newCanonical( 'canonical' );
196 $rr = $renderer->getRenderedRevision( $rev, $options );
197
198 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
199
200 $this->assertSame( $rev, $rr->getRevision() );
201 $this->assertSame( $options, $rr->getOptions() );
202
203 $html = $rr->getRevisionParserOutput()->getText();
204
205 $this->assertContains( 'page:' . __CLASS__, $html );
206 $this->assertContains( 'rev:21', $html );
207 $this->assertContains( 'user:Frank', $html );
208 $this->assertContains( 'time:20180101000003', $html );
209
210 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
211 }
212
213 public function testGetRenderedRevision_master() {
214 $renderer = $this->newRevisionRenderer( 100, true ); // use master
215 $title = $this->getMockTitle( 7, 21 );
216
217 $rev = new MutableRevisionRecord( $title );
218 $rev->setId( 21 ); // current!
219 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
220 $rev->setTimestamp( '20180101000003' );
221 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
222
223 $text = "";
224 $text .= "* page:{{PAGENAME}}\n";
225 $text .= "* rev:{{REVISIONID}}\n";
226 $text .= "* user:{{REVISIONUSER}}\n";
227 $text .= "* time:{{REVISIONTIMESTAMP}}\n";
228
229 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
230
231 $options = ParserOptions::newCanonical( 'canonical' );
232 $rr = $renderer->getRenderedRevision( $rev, $options, null, [ 'use-master' => true ] );
233
234 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
235
236 $html = $rr->getRevisionParserOutput()->getText();
237
238 $this->assertContains( 'rev:21', $html );
239
240 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
241 }
242
243 public function testGetRenderedRevision_known() {
244 $renderer = $this->newRevisionRenderer( 100, true ); // use master
245 $title = $this->getMockTitle( 7, 21 );
246
247 $rev = new MutableRevisionRecord( $title );
248 $rev->setId( 21 ); // current!
249 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
250 $rev->setTimestamp( '20180101000003' );
251 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
252
253 $text = "uncached text";
254 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
255
256 $output = new ParserOutput( 'cached text' );
257
258 $options = ParserOptions::newCanonical( 'canonical' );
259 $rr = $renderer->getRenderedRevision(
260 $rev,
261 $options,
262 null,
263 [ 'known-revision-output' => $output ]
264 );
265
266 $this->assertSame( $output, $rr->getRevisionParserOutput() );
267 $this->assertSame( 'cached text', $rr->getRevisionParserOutput()->getText() );
268 $this->assertSame( 'cached text', $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
269 }
270
271 public function testGetRenderedRevision_old() {
272 $renderer = $this->newRevisionRenderer( 100 );
273 $title = $this->getMockTitle( 7, 21 );
274
275 $rev = new MutableRevisionRecord( $title );
276 $rev->setId( 11 ); // old!
277 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
278 $rev->setTimestamp( '20180101000003' );
279 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
280
281 $text = "";
282 $text .= "* page:{{PAGENAME}}\n";
283 $text .= "* rev:{{REVISIONID}}\n";
284 $text .= "* user:{{REVISIONUSER}}\n";
285 $text .= "* time:{{REVISIONTIMESTAMP}}\n";
286
287 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
288
289 $options = ParserOptions::newCanonical( 'canonical' );
290 $rr = $renderer->getRenderedRevision( $rev, $options );
291
292 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
293
294 $this->assertSame( $rev, $rr->getRevision() );
295 $this->assertSame( $options, $rr->getOptions() );
296
297 $html = $rr->getRevisionParserOutput()->getText();
298
299 $this->assertContains( 'page:' . __CLASS__, $html );
300 $this->assertContains( 'rev:11', $html );
301 $this->assertContains( 'user:Frank', $html );
302 $this->assertContains( 'time:20180101000003', $html );
303
304 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
305 }
306
307 public function testGetRenderedRevision_suppressed() {
308 $renderer = $this->newRevisionRenderer( 100 );
309 $title = $this->getMockTitle( 7, 21 );
310
311 $rev = new MutableRevisionRecord( $title );
312 $rev->setId( 11 ); // old!
313 $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
314 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
315 $rev->setTimestamp( '20180101000003' );
316 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
317
318 $text = "";
319 $text .= "* page:{{PAGENAME}}\n";
320 $text .= "* rev:{{REVISIONID}}\n";
321 $text .= "* user:{{REVISIONUSER}}\n";
322 $text .= "* time:{{REVISIONTIMESTAMP}}\n";
323
324 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
325
326 $options = ParserOptions::newCanonical( 'canonical' );
327 $rr = $renderer->getRenderedRevision( $rev, $options );
328
329 $this->assertNull( $rr, 'getRenderedRevision' );
330 }
331
332 public function testGetRenderedRevision_privileged() {
333 $renderer = $this->newRevisionRenderer( 100 );
334 $title = $this->getMockTitle( 7, 21 );
335
336 $rev = new MutableRevisionRecord( $title );
337 $rev->setId( 11 ); // old!
338 $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
339 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
340 $rev->setTimestamp( '20180101000003' );
341 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
342
343 $text = "";
344 $text .= "* page:{{PAGENAME}}\n";
345 $text .= "* rev:{{REVISIONID}}\n";
346 $text .= "* user:{{REVISIONUSER}}\n";
347 $text .= "* time:{{REVISIONTIMESTAMP}}\n";
348
349 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
350
351 $options = ParserOptions::newCanonical( 'canonical' );
352 $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
353 $rr = $renderer->getRenderedRevision( $rev, $options, $sysop );
354
355 $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
356
357 $this->assertSame( $rev, $rr->getRevision() );
358 $this->assertSame( $options, $rr->getOptions() );
359
360 $html = $rr->getRevisionParserOutput()->getText();
361
362 // Suppressed content should be visible for sysops
363 $this->assertContains( 'page:' . __CLASS__, $html );
364 $this->assertContains( 'rev:11', $html );
365 $this->assertContains( 'user:Frank', $html );
366 $this->assertContains( 'time:20180101000003', $html );
367
368 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
369 }
370
371 public function testGetRenderedRevision_raw() {
372 $renderer = $this->newRevisionRenderer( 100 );
373 $title = $this->getMockTitle( 7, 21 );
374
375 $rev = new MutableRevisionRecord( $title );
376 $rev->setId( 11 ); // old!
377 $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
378 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
379 $rev->setTimestamp( '20180101000003' );
380 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
381
382 $text = "";
383 $text .= "* page:{{PAGENAME}}\n";
384 $text .= "* rev:{{REVISIONID}}\n";
385 $text .= "* user:{{REVISIONUSER}}\n";
386 $text .= "* time:{{REVISIONTIMESTAMP}}\n";
387
388 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
389
390 $options = ParserOptions::newCanonical( 'canonical' );
391 $rr = $renderer->getRenderedRevision(
392 $rev,
393 $options,
394 null,
395 [ 'audience' => RevisionRecord::RAW ]
396 );
397
398 $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
399
400 $this->assertSame( $rev, $rr->getRevision() );
401 $this->assertSame( $options, $rr->getOptions() );
402
403 $html = $rr->getRevisionParserOutput()->getText();
404
405 // Suppressed content should be visible in raw mode
406 $this->assertContains( 'page:' . __CLASS__, $html );
407 $this->assertContains( 'rev:11', $html );
408 $this->assertContains( 'user:Frank', $html );
409 $this->assertContains( 'time:20180101000003', $html );
410
411 $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
412 }
413
414 public function testGetRenderedRevision_multi() {
415 $renderer = $this->newRevisionRenderer();
416 $title = $this->getMockTitle( 7, 21 );
417
418 $rev = new MutableRevisionRecord( $title );
419 $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
420 $rev->setTimestamp( '20180101000003' );
421 $rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
422
423 $rev->setContent( SlotRecord::MAIN, new WikitextContent( '[[Kittens]]' ) );
424 $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
425
426 $rr = $renderer->getRenderedRevision( $rev );
427
428 $combinedOutput = $rr->getRevisionParserOutput();
429 $mainOutput = $rr->getSlotParserOutput( SlotRecord::MAIN );
430 $auxOutput = $rr->getSlotParserOutput( 'aux' );
431
432 $combinedHtml = $combinedOutput->getText();
433 $mainHtml = $mainOutput->getText();
434 $auxHtml = $auxOutput->getText();
435
436 $this->assertContains( 'Kittens', $mainHtml );
437 $this->assertContains( 'Goats', $auxHtml );
438 $this->assertNotContains( 'Goats', $mainHtml );
439 $this->assertNotContains( 'Kittens', $auxHtml );
440 $this->assertContains( 'Kittens', $combinedHtml );
441 $this->assertContains( 'Goats', $combinedHtml );
442 $this->assertContains( '>aux<', $combinedHtml, 'slot header' );
443 $this->assertNotContains( '<mw:slotheader', $combinedHtml, 'slot header placeholder' );
444
445 // make sure output wrapping works right
446 $this->assertContains( 'class="mw-parser-output"', $mainHtml );
447 $this->assertContains( 'class="mw-parser-output"', $auxHtml );
448 $this->assertContains( 'class="mw-parser-output"', $combinedHtml );
449
450 // there should be only one wrapper div
451 $this->assertSame( 1, preg_match_all( '#class="mw-parser-output"#', $combinedHtml ) );
452 $this->assertNotContains( 'class="mw-parser-output"', $combinedOutput->getRawText() );
453
454 $combinedLinks = $combinedOutput->getLinks();
455 $mainLinks = $mainOutput->getLinks();
456 $auxLinks = $auxOutput->getLinks();
457 $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
458 $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
459 $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
460 $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
461 }
462
463 public function testGetRenderedRevision_noHtml() {
464 /** @var MockObject|Content $mockContent */
465 $mockContent = $this->getMockBuilder( WikitextContent::class )
466 ->setMethods( [ 'getParserOutput' ] )
467 ->setConstructorArgs( [ 'Whatever' ] )
468 ->getMock();
469 $mockContent->method( 'getParserOutput' )
470 ->willReturnCallback( function ( Title $title, $revId = null,
471 ParserOptions $options = null, $generateHtml = true
472 ) {
473 if ( !$generateHtml ) {
474 return new ParserOutput( null );
475 } else {
476 $this->fail( 'Should not be called with $generateHtml == true' );
477 return null; // never happens, make analyzer happy
478 }
479 } );
480
481 $renderer = $this->newRevisionRenderer();
482 $title = $this->getMockTitle( 7, 21 );
483
484 $rev = new MutableRevisionRecord( $title );
485 $rev->setContent( SlotRecord::MAIN, $mockContent );
486 $rev->setContent( 'aux', $mockContent );
487
488 // NOTE: we are testing the private combineSlotOutput() callback here.
489 $rr = $renderer->getRenderedRevision( $rev );
490
491 $output = $rr->getSlotParserOutput( SlotRecord::MAIN, [ 'generate-html' => false ] );
492 $this->assertFalse( $output->hasText(), 'hasText' );
493
494 $output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
495 $this->assertFalse( $output->hasText(), 'hasText' );
496 }
497
498 }