Fix @param tags
[lhc/web/wiklou.git] / tests / phpunit / includes / Storage / DerivedPageDataUpdaterTest.php
1 <?php
2
3 namespace MediaWiki\Tests\Storage;
4
5 use CommentStoreComment;
6 use Content;
7 use ContentHandler;
8 use LinksUpdate;
9 use MediaWiki\MediaWikiServices;
10 use MediaWiki\Revision\MutableRevisionRecord;
11 use MediaWiki\Revision\MutableRevisionSlots;
12 use MediaWiki\Revision\RevisionRecord;
13 use MediaWiki\Revision\SlotRecord;
14 use MediaWiki\Storage\DerivedPageDataUpdater;
15 use MediaWiki\Storage\RevisionSlotsUpdate;
16 use MediaWikiTestCase;
17 use MWCallableUpdate;
18 use MWTimestamp;
19 use PHPUnit\Framework\MockObject\MockObject;
20 use TextContent;
21 use TextContentHandler;
22 use Title;
23 use User;
24 use Wikimedia\TestingAccessWrapper;
25 use WikiPage;
26 use WikitextContent;
27
28 /**
29 * @group Database
30 *
31 * @covers \MediaWiki\Storage\DerivedPageDataUpdater
32 */
33 class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
34
35 public function tearDown() {
36 MWTimestamp::setFakeTime( false );
37
38 parent::tearDown();
39 }
40
41 /**
42 * @param string $title
43 *
44 * @return Title
45 */
46 private function getTitle( $title ) {
47 return Title::makeTitleSafe( $this->getDefaultWikitextNS(), $title );
48 }
49
50 /**
51 * @param string|Title $title
52 *
53 * @return WikiPage
54 */
55 private function getPage( $title ) {
56 $title = ( $title instanceof Title ) ? $title : $this->getTitle( $title );
57
58 return WikiPage::factory( $title );
59 }
60
61 /**
62 * @param string|Title|WikiPage $page
63 *
64 * @return DerivedPageDataUpdater
65 */
66 private function getDerivedPageDataUpdater( $page, RevisionRecord $rec = null ) {
67 if ( is_string( $page ) || $page instanceof Title ) {
68 $page = $this->getPage( $page );
69 }
70
71 $page = TestingAccessWrapper::newFromObject( $page );
72 return $page->getDerivedDataUpdater( null, $rec );
73 }
74
75 /**
76 * Creates a revision in the database.
77 *
78 * @param WikiPage $page
79 * @param string|Message|CommentStoreComment $summary
80 * @param null|string|Content $content
81 *
82 * @return RevisionRecord|null
83 */
84 private function createRevision( WikiPage $page, $summary, $content = null ) {
85 $user = $this->getTestUser()->getUser();
86 $comment = CommentStoreComment::newUnsavedComment( $summary );
87
88 if ( $content === null || is_string( $content ) ) {
89 $content = new WikitextContent( $content ?? $summary );
90 }
91
92 if ( !is_array( $content ) ) {
93 $content = [ 'main' => $content ];
94 }
95
96 $this->getDerivedPageDataUpdater( $page ); // flush cached instance before.
97
98 $updater = $page->newPageUpdater( $user );
99
100 foreach ( $content as $role => $c ) {
101 $updater->setContent( $role, $c );
102 }
103
104 $rev = $updater->saveRevision( $comment );
105 if ( !$updater->wasSuccessful() ) {
106 $this->fail( $updater->getStatus()->getWikiText() );
107 }
108
109 $this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
110 return $rev;
111 }
112
113 // TODO: test setArticleCountMethod() and isCountable();
114 // TODO: test isRedirect() and wasRedirect()
115
116 /**
117 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOptions()
118 */
119 public function testGetCanonicalParserOptions() {
120 $user = $this->getTestUser()->getUser();
121 $page = $this->getPage( __METHOD__ );
122
123 $parentRev = $this->createRevision( $page, 'first' );
124
125 $mainContent = new WikitextContent( 'Lorem ipsum' );
126
127 $update = new RevisionSlotsUpdate();
128 $update->modifyContent( SlotRecord::MAIN, $mainContent );
129 $updater = $this->getDerivedPageDataUpdater( $page );
130 $updater->prepareContent( $user, $update, false );
131
132 $options1 = $updater->getCanonicalParserOptions();
133 $this->assertSame( MediaWikiServices::getInstance()->getContentLanguage(),
134 $options1->getUserLangObj() );
135
136 $speculativeId = $options1->getSpeculativeRevId();
137 $this->assertSame( $parentRev->getId() + 1, $speculativeId );
138
139 $rev = $this->makeRevision(
140 $page->getTitle(),
141 $update,
142 $user,
143 $parentRev->getId() + 7,
144 $parentRev->getId()
145 );
146 $updater->prepareUpdate( $rev );
147
148 $options2 = $updater->getCanonicalParserOptions();
149
150 $currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() );
151 $this->assertSame( $rev->getId(), $currentRev->getId() );
152 }
153
154 /**
155 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::grabCurrentRevision()
156 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
157 */
158 public function testGrabCurrentRevision() {
159 $page = $this->getPage( __METHOD__ );
160
161 $updater0 = $this->getDerivedPageDataUpdater( $page );
162 $this->assertNull( $updater0->grabCurrentRevision() );
163 $this->assertFalse( $updater0->pageExisted() );
164
165 $rev1 = $this->createRevision( $page, 'first' );
166 $updater1 = $this->getDerivedPageDataUpdater( $page );
167 $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
168 $this->assertFalse( $updater0->pageExisted() );
169 $this->assertTrue( $updater1->pageExisted() );
170
171 $rev2 = $this->createRevision( $page, 'second' );
172 $updater2 = $this->getDerivedPageDataUpdater( $page );
173 $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
174 $this->assertSame( $rev2->getId(), $updater2->grabCurrentRevision()->getId() );
175 }
176
177 /**
178 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
179 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isContentPrepared()
180 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
181 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
182 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
183 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
184 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
185 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
186 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
187 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
188 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
189 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
190 */
191 public function testPrepareContent() {
192 MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
193 'aux',
194 CONTENT_MODEL_WIKITEXT
195 );
196
197 $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
198 $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
199
200 $this->assertFalse( $updater->isContentPrepared() );
201
202 // TODO: test stash
203 // TODO: MCR: Test multiple slots. Test slot removal.
204 $mainContent = new WikitextContent( 'first [[main]] ~~~' );
205 $auxContent = new WikitextContent( 'inherited ~~~ content' );
206 $auxSlot = SlotRecord::newSaved(
207 10, 7, 'tt:7',
208 SlotRecord::newUnsaved( 'aux', $auxContent )
209 );
210
211 $update = new RevisionSlotsUpdate();
212 $update->modifyContent( SlotRecord::MAIN, $mainContent );
213 $update->modifySlot( SlotRecord::newInherited( $auxSlot ) );
214 // TODO: MCR: test removing slots!
215
216 $updater->prepareContent( $sysop, $update, false );
217
218 // second be ok to call again with the same params
219 $updater->prepareContent( $sysop, $update, false );
220
221 $this->assertNull( $updater->grabCurrentRevision() );
222 $this->assertTrue( $updater->isContentPrepared() );
223 $this->assertFalse( $updater->isUpdatePrepared() );
224 $this->assertFalse( $updater->pageExisted() );
225 $this->assertTrue( $updater->isCreation() );
226 $this->assertTrue( $updater->isChange() );
227 $this->assertFalse( $updater->isContentDeleted() );
228
229 $this->assertNotNull( $updater->getRevision() );
230 $this->assertNotNull( $updater->getRenderedRevision() );
231
232 $this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() );
233 $this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
234 $this->assertEquals( [ 'aux' ], array_keys( $updater->getSlots()->getInheritedSlots() ) );
235 $this->assertEquals( [ 'main', 'aux' ], $updater->getModifiedSlotRoles() );
236 $this->assertEquals( [ 'main', 'aux' ], $updater->getTouchedSlotRoles() );
237
238 $mainSlot = $updater->getRawSlot( SlotRecord::MAIN );
239 $this->assertInstanceOf( SlotRecord::class, $mainSlot );
240 $this->assertNotContains( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' );
241 $this->assertContains( $sysop->getName(), $mainSlot->getContent()->serialize() );
242
243 $auxSlot = $updater->getRawSlot( 'aux' );
244 $this->assertInstanceOf( SlotRecord::class, $auxSlot );
245 $this->assertContains( '~~~', $auxSlot->getContent()->serialize(), 'No PST should apply.' );
246
247 $mainOutput = $updater->getCanonicalParserOutput();
248 $this->assertContains( 'first', $mainOutput->getText() );
249 $this->assertContains( '<a ', $mainOutput->getText() );
250 $this->assertNotEmpty( $mainOutput->getLinks() );
251
252 $canonicalOutput = $updater->getCanonicalParserOutput();
253 $this->assertContains( 'first', $canonicalOutput->getText() );
254 $this->assertContains( '<a ', $canonicalOutput->getText() );
255 $this->assertContains( 'inherited ', $canonicalOutput->getText() );
256 $this->assertNotEmpty( $canonicalOutput->getLinks() );
257 }
258
259 /**
260 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
261 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
262 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
263 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
264 */
265 public function testPrepareContentInherit() {
266 $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
267 $page = $this->getPage( __METHOD__ );
268
269 $mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) #~~~#' );
270 $mainContent2 = new WikitextContent( 'second ({{subst:REVISIONUSER}}) #~~~#' );
271
272 $rev = $this->createRevision( $page, 'first', $mainContent1 );
273 $mainContent1 = $rev->getContent( SlotRecord::MAIN ); // get post-pst content
274 $userName = $rev->getUser()->getName();
275 $sysopName = $sysop->getName();
276
277 $update = new RevisionSlotsUpdate();
278 $update->modifyContent( SlotRecord::MAIN, $mainContent1 );
279 $updater1 = $this->getDerivedPageDataUpdater( $page );
280 $updater1->prepareContent( $sysop, $update, false );
281
282 $this->assertNotNull( $updater1->grabCurrentRevision() );
283 $this->assertTrue( $updater1->isContentPrepared() );
284 $this->assertTrue( $updater1->pageExisted() );
285 $this->assertFalse( $updater1->isCreation() );
286 $this->assertFalse( $updater1->isChange() );
287
288 $this->assertNotNull( $updater1->getRevision() );
289 $this->assertNotNull( $updater1->getRenderedRevision() );
290
291 // parser-output for null-edit uses the original author's name
292 $html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getText();
293 $this->assertNotContains( $sysopName, $html, '{{REVISIONUSER}}' );
294 $this->assertNotContains( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' );
295 $this->assertNotContains( '~~~', $html, 'signature ~~~' );
296 $this->assertContains( '(' . $userName . ')', $html, '{{REVISIONUSER}}' );
297 $this->assertContains( '>' . $userName . '<', $html, 'signature ~~~' );
298
299 // TODO: MCR: test inheritance from parent
300 $update = new RevisionSlotsUpdate();
301 $update->modifyContent( SlotRecord::MAIN, $mainContent2 );
302 $updater2 = $this->getDerivedPageDataUpdater( $page );
303 $updater2->prepareContent( $sysop, $update, false );
304
305 // non-null edit use the new user name in PST
306 $pstText = $updater2->getSlots()->getContent( SlotRecord::MAIN )->serialize();
307 $this->assertNotContains( '{{subst:REVISIONUSER}}', $pstText, '{{subst:REVISIONUSER}}' );
308 $this->assertNotContains( '~~~', $pstText, 'signature ~~~' );
309 $this->assertContains( '(' . $sysopName . ')', $pstText, '{{subst:REVISIONUSER}}' );
310 $this->assertContains( ':' . $sysopName . '|', $pstText, 'signature ~~~' );
311
312 $this->assertFalse( $updater2->isCreation() );
313 $this->assertTrue( $updater2->isChange() );
314 }
315
316 // TODO: test failure of prepareContent() when called again...
317 // - with different user
318 // - with different update
319 // - after calling prepareUpdate()
320
321 /**
322 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
323 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isUpdatePrepared()
324 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
325 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
326 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
327 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
328 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
329 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
330 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
331 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
332 */
333 public function testPrepareUpdate() {
334 $page = $this->getPage( __METHOD__ );
335
336 $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
337 $rev1 = $this->createRevision( $page, 'first', $mainContent1 );
338 $updater1 = $this->getDerivedPageDataUpdater( $page, $rev1 );
339
340 $options = []; // TODO: test *all* the options...
341 $updater1->prepareUpdate( $rev1, $options );
342
343 $this->assertTrue( $updater1->isUpdatePrepared() );
344 $this->assertTrue( $updater1->isContentPrepared() );
345 $this->assertTrue( $updater1->isCreation() );
346 $this->assertTrue( $updater1->isChange() );
347 $this->assertFalse( $updater1->isContentDeleted() );
348
349 $this->assertNotNull( $updater1->getRevision() );
350 $this->assertNotNull( $updater1->getRenderedRevision() );
351
352 $this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() );
353 $this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
354 $this->assertEquals( [], array_keys( $updater1->getSlots()->getInheritedSlots() ) );
355 $this->assertEquals( [ 'main' ], $updater1->getModifiedSlotRoles() );
356 $this->assertEquals( [ 'main' ], $updater1->getTouchedSlotRoles() );
357
358 // TODO: MCR: test multiple slots, test slot removal!
359
360 $this->assertInstanceOf( SlotRecord::class, $updater1->getRawSlot( SlotRecord::MAIN ) );
361 $this->assertNotContains( '~~~~', $updater1->getRawContent( SlotRecord::MAIN )->serialize() );
362
363 $mainOutput = $updater1->getCanonicalParserOutput();
364 $this->assertContains( 'first', $mainOutput->getText() );
365 $this->assertContains( '<a ', $mainOutput->getText() );
366 $this->assertNotEmpty( $mainOutput->getLinks() );
367
368 $canonicalOutput = $updater1->getCanonicalParserOutput();
369 $this->assertContains( 'first', $canonicalOutput->getText() );
370 $this->assertContains( '<a ', $canonicalOutput->getText() );
371 $this->assertNotEmpty( $canonicalOutput->getLinks() );
372
373 $mainContent2 = new WikitextContent( 'second' );
374 $rev2 = $this->createRevision( $page, 'second', $mainContent2 );
375 $updater2 = $this->getDerivedPageDataUpdater( $page, $rev2 );
376
377 $options = []; // TODO: test *all* the options...
378 $updater2->prepareUpdate( $rev2, $options );
379
380 $this->assertFalse( $updater2->isCreation() );
381 $this->assertTrue( $updater2->isChange() );
382
383 $canonicalOutput = $updater2->getCanonicalParserOutput();
384 $this->assertContains( 'second', $canonicalOutput->getText() );
385 }
386
387 /**
388 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
389 */
390 public function testPrepareUpdateReusesParserOutput() {
391 $user = $this->getTestUser()->getUser();
392 $page = $this->getPage( __METHOD__ );
393
394 $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
395
396 $update = new RevisionSlotsUpdate();
397 $update->modifyContent( SlotRecord::MAIN, $mainContent1 );
398 $updater = $this->getDerivedPageDataUpdater( $page );
399 $updater->prepareContent( $user, $update, false );
400
401 $mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN );
402 $canonicalOutput = $updater->getCanonicalParserOutput();
403
404 $rev = $this->createRevision( $page, 'first', $mainContent1 );
405
406 $options = []; // TODO: test *all* the options...
407 $updater->prepareUpdate( $rev, $options );
408
409 $this->assertTrue( $updater->isUpdatePrepared() );
410 $this->assertTrue( $updater->isContentPrepared() );
411
412 $this->assertSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord::MAIN ) );
413 $this->assertSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
414 }
415
416 /**
417 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
418 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
419 */
420 public function testPrepareUpdateOutputReset() {
421 $user = $this->getTestUser()->getUser();
422 $page = $this->getPage( __METHOD__ );
423
424 $mainContent1 = new WikitextContent( 'first --{{REVISIONID}}--' );
425
426 $update = new RevisionSlotsUpdate();
427 $update->modifyContent( SlotRecord::MAIN, $mainContent1 );
428 $updater = $this->getDerivedPageDataUpdater( $page );
429 $updater->prepareContent( $user, $update, false );
430
431 $mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN );
432 $canonicalOutput = $updater->getCanonicalParserOutput();
433
434 // prevent optimization on matching speculative ID
435 $mainOutput->setSpeculativeRevIdUsed( 0 );
436 $canonicalOutput->setSpeculativeRevIdUsed( 0 );
437
438 $rev = $this->createRevision( $page, 'first', $mainContent1 );
439
440 $options = []; // TODO: test *all* the options...
441 $updater->prepareUpdate( $rev, $options );
442
443 $this->assertTrue( $updater->isUpdatePrepared() );
444 $this->assertTrue( $updater->isContentPrepared() );
445
446 // ParserOutput objects should have been flushed.
447 $this->assertNotSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord::MAIN ) );
448 $this->assertNotSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
449
450 $html = $updater->getCanonicalParserOutput()->getText();
451 $this->assertContains( '--' . $rev->getId() . '--', $html );
452
453 // TODO: MCR: ensure that when the main slot uses {{REVISIONID}} but another slot is
454 // updated, the main slot is still re-rendered!
455 }
456
457 // TODO: test failure of prepareUpdate() when called again with a different revision
458 // TODO: test failure of prepareUpdate() on inconsistency with prepareContent.
459
460 /**
461 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
462 */
463 public function testGetPreparedEditAfterPrepareContent() {
464 $user = $this->getTestUser()->getUser();
465
466 $mainContent = new WikitextContent( 'first [[main]] ~~~' );
467 $update = new RevisionSlotsUpdate();
468 $update->modifyContent( SlotRecord::MAIN, $mainContent );
469
470 $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
471 $updater->prepareContent( $user, $update, false );
472
473 $canonicalOutput = $updater->getCanonicalParserOutput();
474
475 $preparedEdit = $updater->getPreparedEdit();
476 $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
477 $this->assertSame( $canonicalOutput, $preparedEdit->output );
478 $this->assertSame( $mainContent, $preparedEdit->newContent );
479 $this->assertSame( $updater->getRawContent( SlotRecord::MAIN ), $preparedEdit->pstContent );
480 $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
481 $this->assertSame( null, $preparedEdit->revid );
482 }
483
484 /**
485 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
486 */
487 public function testGetPreparedEditAfterPrepareUpdate() {
488 $clock = MWTimestamp::convert( TS_UNIX, '20100101000000' );
489 MWTimestamp::setFakeTime( function () use ( &$clock ) {
490 return $clock++;
491 } );
492
493 $page = $this->getPage( __METHOD__ );
494
495 $mainContent = new WikitextContent( 'first [[main]] ~~~' );
496 $update = new MutableRevisionSlots();
497 $update->setContent( SlotRecord::MAIN, $mainContent );
498
499 $rev = $this->createRevision( $page, __METHOD__ );
500
501 $updater = $this->getDerivedPageDataUpdater( $page );
502 $updater->prepareUpdate( $rev );
503
504 $canonicalOutput = $updater->getCanonicalParserOutput();
505
506 $preparedEdit = $updater->getPreparedEdit();
507 $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
508 $this->assertSame( $canonicalOutput, $preparedEdit->output );
509 $this->assertSame( $updater->getRawContent( SlotRecord::MAIN ), $preparedEdit->pstContent );
510 $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
511 $this->assertSame( $rev->getId(), $preparedEdit->revid );
512 }
513
514 public function testGetSecondaryDataUpdatesAfterPrepareContent() {
515 $user = $this->getTestUser()->getUser();
516 $page = $this->getPage( __METHOD__ );
517 $this->createRevision( $page, __METHOD__ );
518
519 $mainContent1 = new WikitextContent( 'first' );
520
521 $update = new RevisionSlotsUpdate();
522 $update->modifyContent( SlotRecord::MAIN, $mainContent1 );
523 $updater = $this->getDerivedPageDataUpdater( $page );
524 $updater->prepareContent( $user, $update, false );
525
526 $dataUpdates = $updater->getSecondaryDataUpdates();
527
528 $this->assertNotEmpty( $dataUpdates );
529
530 $linksUpdates = array_filter( $dataUpdates, function ( $du ) {
531 return $du instanceof LinksUpdate;
532 } );
533 $this->assertCount( 1, $linksUpdates );
534 }
535
536 /**
537 * @param string $name
538 *
539 * @return ContentHandler
540 */
541 private function defineMockContentModelForUpdateTesting( $name ) {
542 /** @var ContentHandler|MockObject $handler */
543 $handler = $this->getMockBuilder( TextContentHandler::class )
544 ->setConstructorArgs( [ $name ] )
545 ->setMethods(
546 [ 'getSecondaryDataUpdates', 'getDeletionUpdates', 'unserializeContent' ]
547 )
548 ->getMock();
549
550 $dataUpdate = new MWCallableUpdate( 'time' );
551 $dataUpdate->_name = "$name data update";
552
553 $deletionUpdate = new MWCallableUpdate( 'time' );
554 $deletionUpdate->_name = "$name deletion update";
555
556 $handler->method( 'getSecondaryDataUpdates' )->willReturn( [ $dataUpdate ] );
557 $handler->method( 'getDeletionUpdates' )->willReturn( [ $deletionUpdate ] );
558 $handler->method( 'unserializeContent' )->willReturnCallback(
559 function ( $text ) use ( $handler ) {
560 return $this->createMockContent( $handler, $text );
561 }
562 );
563
564 $this->mergeMwGlobalArrayValue(
565 'wgContentHandlers', [
566 $name => function () use ( $handler ){
567 return $handler;
568 }
569 ]
570 );
571
572 return $handler;
573 }
574
575 /**
576 * @param ContentHandler $handler
577 * @param string $text
578 *
579 * @return Content
580 */
581 private function createMockContent( ContentHandler $handler, $text ) {
582 /** @var Content|MockObject $content */
583 $content = $this->getMockBuilder( TextContent::class )
584 ->setConstructorArgs( [ $text ] )
585 ->setMethods( [ 'getModel', 'getContentHandler' ] )
586 ->getMock();
587
588 $content->method( 'getModel' )->willReturn( $handler->getModelID() );
589 $content->method( 'getContentHandler' )->willReturn( $handler );
590
591 return $content;
592 }
593
594 public function testGetSecondaryDataUpdatesWithSlotRemoval() {
595 if ( !$this->hasMultiSlotSupport() ) {
596 $this->markTestSkipped( 'Slot removal cannot happen with MCR being enabled' );
597 }
598
599 $m1 = $this->defineMockContentModelForUpdateTesting( 'M1' );
600 $a1 = $this->defineMockContentModelForUpdateTesting( 'A1' );
601 $m2 = $this->defineMockContentModelForUpdateTesting( 'M2' );
602
603 MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
604 'aux',
605 $a1->getModelID()
606 );
607
608 $mainContent1 = $this->createMockContent( $m1, 'main 1' );
609 $auxContent1 = $this->createMockContent( $a1, 'aux 1' );
610 $mainContent2 = $this->createMockContent( $m2, 'main 2' );
611
612 $user = $this->getTestUser()->getUser();
613 $page = $this->getPage( __METHOD__ );
614 $this->createRevision(
615 $page,
616 __METHOD__,
617 [ 'main' => $mainContent1, 'aux' => $auxContent1 ]
618 );
619
620 $update = new RevisionSlotsUpdate();
621 $update->modifyContent( SlotRecord::MAIN, $mainContent2 );
622 $update->removeSlot( 'aux' );
623
624 $page = $this->getPage( __METHOD__ );
625 $updater = $this->getDerivedPageDataUpdater( $page );
626 $updater->prepareContent( $user, $update, false );
627
628 $dataUpdates = $updater->getSecondaryDataUpdates();
629
630 $this->assertNotEmpty( $dataUpdates );
631
632 $updateNames = array_map( function ( $du ) {
633 return isset( $du->_name ) ? $du->_name : get_class( $du );
634 }, $dataUpdates );
635
636 $this->assertContains( LinksUpdate::class, $updateNames );
637 $this->assertContains( 'A1 deletion update', $updateNames );
638 $this->assertContains( 'M2 data update', $updateNames );
639 $this->assertNotContains( 'M1 data update', $updateNames );
640 }
641
642 /**
643 * Creates a dummy revision object without touching the database.
644 *
645 * @param Title $title
646 * @param RevisionSlotsUpdate $update
647 * @param User $user
648 * @param string $comment
649 * @param int $id
650 * @param int $parentId
651 *
652 * @return MutableRevisionRecord
653 */
654 private function makeRevision(
655 Title $title,
656 RevisionSlotsUpdate $update,
657 User $user,
658 $comment,
659 $id = 0,
660 $parentId = 0
661 ) {
662 $rev = new MutableRevisionRecord( $title );
663
664 $rev->applyUpdate( $update );
665 $rev->setUser( $user );
666 $rev->setComment( CommentStoreComment::newUnsavedComment( $comment ) );
667 $rev->setPageId( $title->getArticleID() );
668 $rev->setParentId( $parentId );
669
670 if ( $id ) {
671 $rev->setId( $id );
672 }
673
674 return $rev;
675 }
676
677 /**
678 * @param int $id
679 * @return Title
680 */
681 private function getMockTitle( $id = 23 ) {
682 $mock = $this->getMockBuilder( Title::class )
683 ->disableOriginalConstructor()
684 ->getMock();
685 $mock->expects( $this->any() )
686 ->method( 'getDBkey' )
687 ->will( $this->returnValue( __CLASS__ ) );
688 $mock->expects( $this->any() )
689 ->method( 'getArticleID' )
690 ->will( $this->returnValue( $id ) );
691
692 return $mock;
693 }
694
695 public function provideIsReusableFor() {
696 $title = $this->getMockTitle();
697
698 $user1 = User::newFromName( 'Alice' );
699 $user2 = User::newFromName( 'Bob' );
700
701 $content1 = new WikitextContent( 'one' );
702 $content2 = new WikitextContent( 'two' );
703
704 $update1 = new RevisionSlotsUpdate();
705 $update1->modifyContent( SlotRecord::MAIN, $content1 );
706
707 $update1b = new RevisionSlotsUpdate();
708 $update1b->modifyContent( 'xyz', $content1 );
709
710 $update2 = new RevisionSlotsUpdate();
711 $update2->modifyContent( SlotRecord::MAIN, $content2 );
712
713 $rev1 = $this->makeRevision( $title, $update1, $user1, 'rev1', 11 );
714 $rev1b = $this->makeRevision( $title, $update1b, $user1, 'rev1', 11 );
715
716 $rev2 = $this->makeRevision( $title, $update2, $user1, 'rev2', 12 );
717 $rev2x = $this->makeRevision( $title, $update2, $user2, 'rev2', 12 );
718 $rev2y = $this->makeRevision( $title, $update2, $user1, 'rev2', 122 );
719
720 yield 'any' => [
721 '$prepUser' => null,
722 '$prepRevision' => null,
723 '$prepUpdate' => null,
724 '$forUser' => null,
725 '$forRevision' => null,
726 '$forUpdate' => null,
727 '$forParent' => null,
728 '$isReusable' => true,
729 ];
730 yield 'for any' => [
731 '$prepUser' => $user1,
732 '$prepRevision' => $rev1,
733 '$prepUpdate' => $update1,
734 '$forUser' => null,
735 '$forRevision' => null,
736 '$forUpdate' => null,
737 '$forParent' => null,
738 '$isReusable' => true,
739 ];
740 yield 'unprepared' => [
741 '$prepUser' => null,
742 '$prepRevision' => null,
743 '$prepUpdate' => null,
744 '$forUser' => $user1,
745 '$forRevision' => $rev1,
746 '$forUpdate' => $update1,
747 '$forParent' => 0,
748 '$isReusable' => true,
749 ];
750 yield 'match prepareContent' => [
751 '$prepUser' => $user1,
752 '$prepRevision' => null,
753 '$prepUpdate' => $update1,
754 '$forUser' => $user1,
755 '$forRevision' => null,
756 '$forUpdate' => $update1,
757 '$forParent' => 0,
758 '$isReusable' => true,
759 ];
760 yield 'match prepareUpdate' => [
761 '$prepUser' => null,
762 '$prepRevision' => $rev1,
763 '$prepUpdate' => null,
764 '$forUser' => $user1,
765 '$forRevision' => $rev1,
766 '$forUpdate' => null,
767 '$forParent' => 0,
768 '$isReusable' => true,
769 ];
770 yield 'match all' => [
771 '$prepUser' => $user1,
772 '$prepRevision' => $rev1,
773 '$prepUpdate' => $update1,
774 '$forUser' => $user1,
775 '$forRevision' => $rev1,
776 '$forUpdate' => $update1,
777 '$forParent' => 0,
778 '$isReusable' => true,
779 ];
780 yield 'mismatch prepareContent update' => [
781 '$prepUser' => $user1,
782 '$prepRevision' => null,
783 '$prepUpdate' => $update1,
784 '$forUser' => $user1,
785 '$forRevision' => null,
786 '$forUpdate' => $update1b,
787 '$forParent' => 0,
788 '$isReusable' => false,
789 ];
790 yield 'mismatch prepareContent user' => [
791 '$prepUser' => $user1,
792 '$prepRevision' => null,
793 '$prepUpdate' => $update1,
794 '$forUser' => $user2,
795 '$forRevision' => null,
796 '$forUpdate' => $update1,
797 '$forParent' => 0,
798 '$isReusable' => false,
799 ];
800 yield 'mismatch prepareContent parent' => [
801 '$prepUser' => $user1,
802 '$prepRevision' => null,
803 '$prepUpdate' => $update1,
804 '$forUser' => $user1,
805 '$forRevision' => null,
806 '$forUpdate' => $update1,
807 '$forParent' => 7,
808 '$isReusable' => false,
809 ];
810 yield 'mismatch prepareUpdate revision update' => [
811 '$prepUser' => null,
812 '$prepRevision' => $rev1,
813 '$prepUpdate' => null,
814 '$forUser' => null,
815 '$forRevision' => $rev1b,
816 '$forUpdate' => null,
817 '$forParent' => 0,
818 '$isReusable' => false,
819 ];
820 yield 'mismatch prepareUpdate revision id' => [
821 '$prepUser' => null,
822 '$prepRevision' => $rev2,
823 '$prepUpdate' => null,
824 '$forUser' => null,
825 '$forRevision' => $rev2y,
826 '$forUpdate' => null,
827 '$forParent' => 0,
828 '$isReusable' => false,
829 ];
830 }
831
832 /**
833 * @dataProvider provideIsReusableFor
834 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isReusableFor()
835 *
836 * @param User|null $prepUser
837 * @param RevisionRecord|null $prepRevision
838 * @param RevisionSlotsUpdate|null $prepUpdate
839 * @param User|null $forUser
840 * @param RevisionRecord|null $forRevision
841 * @param RevisionSlotsUpdate|null $forUpdate
842 * @param int|null $forParent
843 * @param bool $isReusable
844 */
845 public function testIsReusableFor(
846 User $prepUser = null,
847 RevisionRecord $prepRevision = null,
848 RevisionSlotsUpdate $prepUpdate = null,
849 User $forUser = null,
850 RevisionRecord $forRevision = null,
851 RevisionSlotsUpdate $forUpdate = null,
852 $forParent = null,
853 $isReusable = null
854 ) {
855 $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
856
857 if ( $prepUpdate ) {
858 $updater->prepareContent( $prepUser, $prepUpdate, false );
859 }
860
861 if ( $prepRevision ) {
862 $updater->prepareUpdate( $prepRevision );
863 }
864
865 $this->assertSame(
866 $isReusable,
867 $updater->isReusableFor( $forUser, $forRevision, $forUpdate, $forParent )
868 );
869 }
870
871 /**
872 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
873 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
874 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
875 */
876 public function testDoUpdates() {
877 $page = $this->getPage( __METHOD__ );
878
879 $content = [ 'main' => new WikitextContent( 'first [[main]]' ) ];
880
881 if ( $this->hasMultiSlotSupport() ) {
882 $content['aux'] = new WikitextContent( 'Aux [[Nix]]' );
883
884 MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
885 'aux',
886 CONTENT_MODEL_WIKITEXT
887 );
888 }
889
890 $rev = $this->createRevision( $page, 'first', $content );
891 $pageId = $page->getId();
892
893 $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
894 $this->db->delete( 'pagelinks', '*' );
895
896 $pcache = MediaWikiServices::getInstance()->getParserCache();
897 $pcache->deleteOptionsKey( $page );
898
899 $updater = $this->getDerivedPageDataUpdater( $page, $rev );
900 $updater->setArticleCountMethod( 'link' );
901
902 $options = []; // TODO: test *all* the options...
903 $updater->prepareUpdate( $rev, $options );
904
905 $updater->doUpdates();
906
907 // links table update
908 $pageLinks = $this->db->select(
909 'pagelinks',
910 '*',
911 [ 'pl_from' => $pageId ],
912 __METHOD__,
913 [ 'ORDER BY' => 'pl_namespace, pl_title' ]
914 );
915
916 $pageLinksRow = $pageLinks->fetchObject();
917 $this->assertInternalType( 'object', $pageLinksRow );
918 $this->assertSame( 'Main', $pageLinksRow->pl_title );
919
920 if ( $this->hasMultiSlotSupport() ) {
921 $pageLinksRow = $pageLinks->fetchObject();
922 $this->assertInternalType( 'object', $pageLinksRow );
923 $this->assertSame( 'Nix', $pageLinksRow->pl_title );
924 }
925
926 // parser cache update
927 $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
928 $this->assertInternalType( 'object', $cached );
929 $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
930
931 // site stats
932 $stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
933 $this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
934 $this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );
935 $this->assertSame( $oldStats->ss_good_articles + 1, (int)$stats->ss_good_articles );
936
937 // TODO: MCR: test data updates for additional slots!
938 // TODO: test update for edit without page creation
939 // TODO: test message cache purge
940 // TODO: test module cache purge
941 // TODO: test CDN purge
942 // TODO: test newtalk update
943 // TODO: test search update
944 // TODO: test site stats good_articles while turning the page into (or back from) a redir.
945 // TODO: test category membership update (with setRcWatchCategoryMembership())
946 }
947
948 /**
949 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
950 */
951 public function testDoParserCacheUpdate() {
952 if ( $this->hasMultiSlotSupport() ) {
953 MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
954 'aux',
955 CONTENT_MODEL_WIKITEXT
956 );
957 }
958
959 $page = $this->getPage( __METHOD__ );
960 $this->createRevision( $page, 'Dummy' );
961
962 $user = $this->getTestUser()->getUser();
963
964 $update = new RevisionSlotsUpdate();
965 $update->modifyContent( 'main', new WikitextContent( 'first [[Main]]' ) );
966
967 if ( $this->hasMultiSlotSupport() ) {
968 $update->modifyContent( 'aux', new WikitextContent( 'Aux [[Nix]]' ) );
969 }
970
971 // Emulate update after edit ----------
972 $pcache = MediaWikiServices::getInstance()->getParserCache();
973 $pcache->deleteOptionsKey( $page );
974
975 $rev = $this->makeRevision( $page->getTitle(), $update, $user, 'rev', null );
976 $rev->setTimestamp( '20100101000000' );
977 $rev->setParentId( $page->getLatest() );
978
979 $updater = $this->getDerivedPageDataUpdater( $page );
980 $updater->prepareContent( $user, $update, false );
981
982 $rev->setId( 11 );
983 $updater->prepareUpdate( $rev );
984
985 // Force the page timestamp, so we notice whether ParserOutput::getTimestamp
986 // or ParserOutput::getCacheTime are used.
987 $page->setTimestamp( $rev->getTimestamp() );
988 $updater->doParserCacheUpdate();
989
990 // The cached ParserOutput should not use the revision timestamp
991 $cached = $pcache->get( $page, $updater->getCanonicalParserOptions(), true );
992 $this->assertInternalType( 'object', $cached );
993 $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
994
995 $this->assertSame( $rev->getTimestamp(), $cached->getCacheTime() );
996 $this->assertSame( $rev->getId(), $cached->getCacheRevisionId() );
997
998 // Emulate forced update of an old revision ----------
999 $pcache->deleteOptionsKey( $page );
1000
1001 $updater = $this->getDerivedPageDataUpdater( $page );
1002 $updater->prepareUpdate( $rev );
1003
1004 // Force the page timestamp, so we notice whether ParserOutput::getTimestamp
1005 // or ParserOutput::getCacheTime are used.
1006 $page->setTimestamp( $rev->getTimestamp() );
1007 $updater->doParserCacheUpdate();
1008
1009 // The cached ParserOutput should not use the revision timestamp
1010 $cached = $pcache->get( $page, $updater->getCanonicalParserOptions(), true );
1011 $this->assertInternalType( 'object', $cached );
1012 $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
1013
1014 $this->assertGreaterThan( $rev->getTimestamp(), $cached->getCacheTime() );
1015 $this->assertSame( $rev->getId(), $cached->getCacheRevisionId() );
1016 }
1017
1018 /**
1019 * @return bool
1020 */
1021 private function hasMultiSlotSupport() {
1022 global $wgMultiContentRevisionSchemaMigrationStage;
1023
1024 return ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW )
1025 && ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW );
1026 }
1027
1028 }