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