Merge "output: Narrow Title type hint to LinkTarget"
[lhc/web/wiklou.git] / tests / phpunit / includes / Revision / RenderedRevisionTest.php
1 <?php
2
3 namespace MediaWiki\Tests\Revision;
4
5 use Content;
6 use Language;
7 use MediaWiki\MediaWikiServices;
8 use MediaWiki\Revision\MutableRevisionRecord;
9 use MediaWiki\Revision\MutableRevisionSlots;
10 use MediaWiki\Revision\RenderedRevision;
11 use MediaWiki\Revision\RevisionArchiveRecord;
12 use MediaWiki\Revision\RevisionRecord;
13 use MediaWiki\Revision\RevisionStore;
14 use MediaWiki\Revision\RevisionStoreRecord;
15 use MediaWiki\Revision\SlotRecord;
16 use MediaWiki\Revision\SuppressedDataException;
17 use MediaWikiTestCase;
18 use MediaWiki\User\UserIdentityValue;
19 use ParserOptions;
20 use ParserOutput;
21 use PHPUnit\Framework\MockObject\MockObject;
22 use Title;
23 use User;
24 use Wikimedia\TestingAccessWrapper;
25 use WikitextContent;
26
27 /**
28 * @covers \MediaWiki\Revision\RenderedRevision
29 */
30 class RenderedRevisionTest extends MediaWikiTestCase {
31
32 /** @var callable */
33 private $combinerCallback;
34
35 public function setUp() {
36 parent::setUp();
37
38 $this->combinerCallback = function ( RenderedRevision $rr, array $hints = [] ) {
39 return $this->combineOutput( $rr, $hints );
40 };
41 }
42
43 private function combineOutput( RenderedRevision $rrev, array $hints = [] ) {
44 // NOTE: the is a slightly simplified version of RevisionRenderer::combineSlotOutput
45
46 $withHtml = $hints['generate-html'] ?? true;
47
48 $revision = $rrev->getRevision();
49 $slots = $revision->getSlots()->getSlots();
50
51 $combinedOutput = new ParserOutput( null );
52 $slotOutput = [];
53 foreach ( $slots as $role => $slot ) {
54 $out = $rrev->getSlotParserOutput( $role, $hints );
55 $slotOutput[$role] = $out;
56
57 $combinedOutput->mergeInternalMetaDataFrom( $out );
58 $combinedOutput->mergeTrackingMetaDataFrom( $out );
59 }
60
61 if ( $withHtml ) {
62 $html = '';
63 /** @var ParserOutput $out */
64 foreach ( $slotOutput as $role => $out ) {
65
66 if ( $html !== '' ) {
67 // skip header for the first slot
68 $html .= "(($role))";
69 }
70
71 $html .= $out->getRawText();
72 $combinedOutput->mergeHtmlMetaDataFrom( $out );
73 }
74
75 $combinedOutput->setText( $html );
76 }
77
78 return $combinedOutput;
79 }
80
81 /**
82 * @param int $articleId
83 * @param int $revisionId
84 * @return Title
85 */
86 private function getMockTitle( $articleId, $revisionId ) {
87 /** @var Title|MockObject $mock */
88 $mock = $this->getMockBuilder( Title::class )
89 ->disableOriginalConstructor()
90 ->getMock();
91 $mock->expects( $this->any() )
92 ->method( 'getNamespace' )
93 ->will( $this->returnValue( NS_MAIN ) );
94 $mock->expects( $this->any() )
95 ->method( 'getText' )
96 ->will( $this->returnValue( 'RenderTestPage' ) );
97 $mock->expects( $this->any() )
98 ->method( 'getPrefixedText' )
99 ->will( $this->returnValue( 'RenderTestPage' ) );
100 $mock->expects( $this->any() )
101 ->method( 'getDBkey' )
102 ->will( $this->returnValue( 'RenderTestPage' ) );
103 $mock->expects( $this->any() )
104 ->method( 'getArticleID' )
105 ->will( $this->returnValue( $articleId ) );
106 $mock->expects( $this->any() )
107 ->method( 'getLatestRevId' )
108 ->will( $this->returnValue( $revisionId ) );
109 $mock->expects( $this->any() )
110 ->method( 'getContentModel' )
111 ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) );
112 $mock->expects( $this->any() )
113 ->method( 'getPageLanguage' )
114 ->will( $this->returnValue( Language::factory( 'en' ) ) );
115 $mock->expects( $this->any() )
116 ->method( 'isContentPage' )
117 ->will( $this->returnValue( true ) );
118 $mock->expects( $this->any() )
119 ->method( 'equals' )
120 ->willReturnCallback( function ( Title $other ) use ( $mock ) {
121 return $mock->getPrefixedText() === $other->getPrefixedText();
122 } );
123 $mock->expects( $this->any() )
124 ->method( 'userCan' )
125 ->willReturnCallback( function ( $perm, User $user ) use ( $mock ) {
126 return MediaWikiServices::getInstance()
127 ->getPermissionManager()
128 ->userHasRight( $user, $perm );
129 } );
130
131 return $mock;
132 }
133
134 /**
135 * @param string $class
136 * @param Title $title
137 * @param null|int $id
138 * @param int $visibility
139 * @return RevisionRecord
140 */
141 private function getMockRevision(
142 $class,
143 $title,
144 $id = null,
145 $visibility = 0,
146 array $content = null
147 ) {
148 $frank = new UserIdentityValue( 9, 'Frank', 0 );
149
150 if ( !$content ) {
151 $text = "";
152 $text .= "* page:{{PAGENAME}}!\n";
153 $text .= "* rev:{{REVISIONID}}!\n";
154 $text .= "* user:{{REVISIONUSER}}!\n";
155 $text .= "* time:{{REVISIONTIMESTAMP}}!\n";
156 $text .= "* [[Link It]]\n";
157
158 $content = [ 'main' => new WikitextContent( $text ) ];
159 }
160
161 /** @var MockObject|RevisionRecord $mock */
162 $mock = $this->getMockBuilder( $class )
163 ->disableOriginalConstructor()
164 ->setMethods( [
165 'getId',
166 'getPageId',
167 'getPageAsLinkTarget',
168 'getUser',
169 'getVisibility',
170 'getTimestamp',
171 ] )->getMock();
172
173 $mock->method( 'getId' )->willReturn( $id );
174 $mock->method( 'getPageId' )->willReturn( $title->getArticleID() );
175 $mock->method( 'getPageAsLinkTarget' )->willReturn( $title );
176 $mock->method( 'getUser' )->willReturn( $frank );
177 $mock->method( 'getVisibility' )->willReturn( $visibility );
178 $mock->method( 'getTimestamp' )->willReturn( '20180101000003' );
179
180 /** @var object $mockAccess */
181 $mockAccess = TestingAccessWrapper::newFromObject( $mock );
182 $mockAccess->mSlots = new MutableRevisionSlots();
183
184 foreach ( $content as $role => $cnt ) {
185 $mockAccess->mSlots->setContent( $role, $cnt );
186 }
187
188 return $mock;
189 }
190
191 public function testGetRevisionParserOutput_new() {
192 $title = $this->getMockTitle( 0, 21 );
193 $rev = $this->getMockRevision( RevisionStoreRecord::class, $title );
194
195 $options = ParserOptions::newCanonical( 'canonical' );
196 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
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:RenderTestPage!', $html );
206 $this->assertContains( 'user:Frank!', $html );
207 $this->assertContains( 'time:20180101000003!', $html );
208 }
209
210 public function testGetRevisionParserOutput_previewWithSelfTransclusion() {
211 $title = $this->getMockTitle( 0, 21 );
212 $name = $title->getPrefixedText();
213
214 $text = "(ONE)<includeonly>(TWO)</includeonly><noinclude>#{{:$name}}#</noinclude>";
215
216 $content = [
217 'main' => new WikitextContent( $text )
218 ];
219
220 $rev = $this->getMockRevision( RevisionStoreRecord::class, $title, null, 0, $content );
221
222 $options = ParserOptions::newCanonical( 'canonical' );
223 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
224
225 $html = $rr->getRevisionParserOutput()->getText();
226 $this->assertContains( '(ONE)#(ONE)(TWO)#', $html );
227 }
228
229 public function testGetRevisionParserOutput_current() {
230 $title = $this->getMockTitle( 7, 21 );
231 $rev = $this->getMockRevision( RevisionStoreRecord::class, $title, 21 );
232
233 $options = ParserOptions::newCanonical( 'canonical' );
234 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
235
236 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
237
238 $this->assertSame( $rev, $rr->getRevision() );
239 $this->assertSame( $options, $rr->getOptions() );
240
241 $html = $rr->getRevisionParserOutput()->getText();
242
243 $this->assertContains( 'page:RenderTestPage!', $html );
244 $this->assertContains( 'rev:21!', $html );
245 $this->assertContains( 'user:Frank!', $html );
246 $this->assertContains( 'time:20180101000003!', $html );
247
248 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
249 }
250
251 public function testGetRevisionParserOutput_old() {
252 $title = $this->getMockTitle( 7, 21 );
253 $rev = $this->getMockRevision( RevisionStoreRecord::class, $title, 11 );
254
255 $options = ParserOptions::newCanonical( 'canonical' );
256 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
257
258 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
259
260 $this->assertSame( $rev, $rr->getRevision() );
261 $this->assertSame( $options, $rr->getOptions() );
262
263 $html = $rr->getRevisionParserOutput()->getText();
264
265 $this->assertContains( 'page:RenderTestPage!', $html );
266 $this->assertContains( 'rev:11!', $html );
267 $this->assertContains( 'user:Frank!', $html );
268 $this->assertContains( 'time:20180101000003!', $html );
269
270 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
271 }
272
273 public function testGetRevisionParserOutput_archive() {
274 $title = $this->getMockTitle( 7, 21 );
275 $rev = $this->getMockRevision( RevisionArchiveRecord::class, $title, 11 );
276
277 $options = ParserOptions::newCanonical( 'canonical' );
278 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
279
280 $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
281
282 $this->assertSame( $rev, $rr->getRevision() );
283 $this->assertSame( $options, $rr->getOptions() );
284
285 $html = $rr->getRevisionParserOutput()->getText();
286
287 $this->assertContains( 'page:RenderTestPage!', $html );
288 $this->assertContains( 'rev:11!', $html );
289 $this->assertContains( 'user:Frank!', $html );
290 $this->assertContains( 'time:20180101000003!', $html );
291
292 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
293 }
294
295 public function testGetRevisionParserOutput_suppressed() {
296 $title = $this->getMockTitle( 7, 21 );
297 $rev = $this->getMockRevision(
298 RevisionStoreRecord::class,
299 $title,
300 11,
301 RevisionRecord::DELETED_TEXT
302 );
303
304 $options = ParserOptions::newCanonical( 'canonical' );
305 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
306
307 $this->setExpectedException( SuppressedDataException::class );
308 $rr->getRevisionParserOutput();
309 }
310
311 public function testGetRevisionParserOutput_privileged() {
312 $title = $this->getMockTitle( 7, 21 );
313 $rev = $this->getMockRevision(
314 RevisionStoreRecord::class,
315 $title,
316 11,
317 RevisionRecord::DELETED_TEXT
318 );
319
320 $options = ParserOptions::newCanonical( 'canonical' );
321 $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
322 $rr = new RenderedRevision(
323 $title,
324 $rev,
325 $options,
326 $this->combinerCallback,
327 RevisionRecord::FOR_THIS_USER,
328 $sysop
329 );
330
331 $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
332
333 $this->assertSame( $rev, $rr->getRevision() );
334 $this->assertSame( $options, $rr->getOptions() );
335
336 $html = $rr->getRevisionParserOutput()->getText();
337
338 // Suppressed content should be visible for sysops
339 $this->assertContains( 'page:RenderTestPage!', $html );
340 $this->assertContains( 'rev:11!', $html );
341 $this->assertContains( 'user:Frank!', $html );
342 $this->assertContains( 'time:20180101000003!', $html );
343
344 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
345 }
346
347 public function testGetRevisionParserOutput_raw() {
348 $title = $this->getMockTitle( 7, 21 );
349 $rev = $this->getMockRevision(
350 RevisionStoreRecord::class,
351 $title,
352 11,
353 RevisionRecord::DELETED_TEXT
354 );
355
356 $options = ParserOptions::newCanonical( 'canonical' );
357 $rr = new RenderedRevision(
358 $title,
359 $rev,
360 $options,
361 $this->combinerCallback,
362 RevisionRecord::RAW
363 );
364
365 $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
366
367 $this->assertSame( $rev, $rr->getRevision() );
368 $this->assertSame( $options, $rr->getOptions() );
369
370 $html = $rr->getRevisionParserOutput()->getText();
371
372 // Suppressed content should be visible for sysops
373 $this->assertContains( 'page:RenderTestPage!', $html );
374 $this->assertContains( 'rev:11!', $html );
375 $this->assertContains( 'user:Frank!', $html );
376 $this->assertContains( 'time:20180101000003!', $html );
377
378 $this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
379 }
380
381 public function testGetRevisionParserOutput_multi() {
382 $content = [
383 'main' => new WikitextContent( '[[Kittens]]' ),
384 'aux' => new WikitextContent( '[[Goats]]' ),
385 ];
386
387 $title = $this->getMockTitle( 7, 21 );
388 $rev = $this->getMockRevision( RevisionStoreRecord::class, $title, 11, 0, $content );
389
390 $options = ParserOptions::newCanonical( 'canonical' );
391 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
392
393 $combinedOutput = $rr->getRevisionParserOutput();
394 $mainOutput = $rr->getSlotParserOutput( SlotRecord::MAIN );
395 $auxOutput = $rr->getSlotParserOutput( 'aux' );
396
397 $combinedHtml = $combinedOutput->getText();
398 $mainHtml = $mainOutput->getText();
399 $auxHtml = $auxOutput->getText();
400
401 $this->assertContains( 'Kittens', $mainHtml );
402 $this->assertContains( 'Goats', $auxHtml );
403 $this->assertNotContains( 'Goats', $mainHtml );
404 $this->assertNotContains( 'Kittens', $auxHtml );
405 $this->assertContains( 'Kittens', $combinedHtml );
406 $this->assertContains( 'Goats', $combinedHtml );
407 $this->assertContains( 'aux', $combinedHtml, 'slot section header' );
408
409 $combinedLinks = $combinedOutput->getLinks();
410 $mainLinks = $mainOutput->getLinks();
411 $auxLinks = $auxOutput->getLinks();
412 $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
413 $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
414 $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
415 $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
416 }
417
418 public function testGetRevisionParserOutput_incompleteNoId() {
419 $title = $this->getMockTitle( 7, 21 );
420
421 $rev = new MutableRevisionRecord( $title );
422
423 $text = "";
424 $text .= "* page:{{PAGENAME}}!\n";
425 $text .= "* rev:{{REVISIONID}}!\n";
426 $text .= "* user:{{REVISIONUSER}}!\n";
427 $text .= "* time:{{REVISIONTIMESTAMP}}!\n";
428
429 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
430
431 $options = ParserOptions::newCanonical( 'canonical' );
432 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
433
434 // MutableRevisionRecord without ID should be used by the parser.
435 // USeful for fake
436 $html = $rr->getRevisionParserOutput()->getText();
437
438 $this->assertContains( 'page:RenderTestPage!', $html );
439 $this->assertContains( 'rev:!', $html );
440 $this->assertContains( 'user:!', $html );
441 $this->assertContains( 'time:!', $html );
442 }
443
444 public function testGetRevisionParserOutput_incompleteWithId() {
445 $title = $this->getMockTitle( 7, 21 );
446
447 $rev = new MutableRevisionRecord( $title );
448 $rev->setId( 21 );
449
450 $text = "";
451 $text .= "* page:{{PAGENAME}}!\n";
452 $text .= "* rev:{{REVISIONID}}!\n";
453 $text .= "* user:{{REVISIONUSER}}!\n";
454 $text .= "* time:{{REVISIONTIMESTAMP}}!\n";
455
456 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
457
458 $actualRevision = $this->getMockRevision(
459 RevisionStoreRecord::class,
460 $title,
461 21,
462 RevisionRecord::DELETED_TEXT
463 );
464
465 $options = ParserOptions::newCanonical( 'canonical' );
466 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
467
468 // MutableRevisionRecord with ID should not be used by the parser,
469 // revision should be loaded instead!
470 $revisionStore = $this->getMockBuilder( RevisionStore::class )
471 ->disableOriginalConstructor()
472 ->getMock();
473
474 $revisionStore->expects( $this->once() )
475 ->method( 'getKnownCurrentRevision' )
476 ->with( $title, 0 )
477 ->willReturn( $actualRevision );
478
479 $this->setService( 'RevisionStore', $revisionStore );
480
481 $html = $rr->getRevisionParserOutput()->getText();
482
483 $this->assertContains( 'page:RenderTestPage!', $html );
484 $this->assertContains( 'rev:21!', $html );
485 $this->assertContains( 'user:Frank!', $html );
486 $this->assertContains( 'time:20180101000003!', $html );
487 }
488
489 public function testSetRevisionParserOutput() {
490 $title = $this->getMockTitle( 3, 21 );
491 $rev = $this->getMockRevision( RevisionStoreRecord::class, $title );
492
493 $options = ParserOptions::newCanonical( 'canonical' );
494 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
495
496 $output = new ParserOutput( 'Kittens' );
497 $rr->setRevisionParserOutput( $output );
498
499 $this->assertSame( $output, $rr->getRevisionParserOutput() );
500 $this->assertSame( 'Kittens', $rr->getRevisionParserOutput()->getText() );
501
502 $this->assertSame( $output, $rr->getSlotParserOutput( SlotRecord::MAIN ) );
503 $this->assertSame( 'Kittens', $rr->getSlotParserOutput( SlotRecord::MAIN )->getText() );
504 }
505
506 public function testNoHtml() {
507 /** @var MockObject|Content $mockContent */
508 $mockContent = $this->getMockBuilder( WikitextContent::class )
509 ->setMethods( [ 'getParserOutput' ] )
510 ->setConstructorArgs( [ 'Whatever' ] )
511 ->getMock();
512 $mockContent->method( 'getParserOutput' )
513 ->willReturnCallback( function ( Title $title, $revId = null,
514 ParserOptions $options = null, $generateHtml = true
515 ) {
516 if ( !$generateHtml ) {
517 return new ParserOutput( null );
518 } else {
519 $this->fail( 'Should not be called with $generateHtml == true' );
520 return null; // never happens, make analyzer happy
521 }
522 } );
523
524 $title = $this->getMockTitle( 7, 21 );
525
526 $rev = new MutableRevisionRecord( $title );
527 $rev->setContent( SlotRecord::MAIN, $mockContent );
528 $rev->setContent( 'aux', $mockContent );
529
530 $options = ParserOptions::newCanonical( 'canonical' );
531 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
532
533 $output = $rr->getSlotParserOutput( SlotRecord::MAIN, [ 'generate-html' => false ] );
534 $this->assertFalse( $output->hasText(), 'hasText' );
535
536 $output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
537 $this->assertFalse( $output->hasText(), 'hasText' );
538 }
539
540 public function testUpdateRevision() {
541 $title = $this->getMockTitle( 7, 21 );
542
543 $rev = new MutableRevisionRecord( $title );
544
545 $text = "";
546 $text .= "* page:{{PAGENAME}}!\n";
547 $text .= "* rev:{{REVISIONID}}!\n";
548 $text .= "* user:{{REVISIONUSER}}!\n";
549 $text .= "* time:{{REVISIONTIMESTAMP}}!\n";
550
551 $rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
552 $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
553
554 $options = ParserOptions::newCanonical( 'canonical' );
555 $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
556
557 $firstOutput = $rr->getRevisionParserOutput();
558 $mainOutput = $rr->getSlotParserOutput( SlotRecord::MAIN );
559 $auxOutput = $rr->getSlotParserOutput( 'aux' );
560
561 // emulate a saved revision
562 $savedRev = new MutableRevisionRecord( $title );
563 $savedRev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
564 $savedRev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
565 $savedRev->setId( 23 ); // saved, new
566 $savedRev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
567 $savedRev->setTimestamp( '20180101000003' );
568
569 $rr->updateRevision( $savedRev );
570
571 $this->assertNotSame( $mainOutput, $rr->getSlotParserOutput( SlotRecord::MAIN ), 'Reset main' );
572 $this->assertSame( $auxOutput, $rr->getSlotParserOutput( 'aux' ), 'Keep aux' );
573
574 $updatedOutput = $rr->getRevisionParserOutput();
575 $html = $updatedOutput->getText();
576
577 $this->assertNotSame( $firstOutput, $updatedOutput, 'Reset merged' );
578 $this->assertContains( 'page:RenderTestPage!', $html );
579 $this->assertContains( 'rev:23!', $html );
580 $this->assertContains( 'user:Frank!', $html );
581 $this->assertContains( 'time:20180101000003!', $html );
582 $this->assertContains( 'Goats', $html );
583
584 $rr->updateRevision( $savedRev ); // should do nothing
585 $this->assertSame( $updatedOutput, $rr->getRevisionParserOutput(), 'no more reset needed' );
586 }
587
588 }