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