1<?php
2
3use MediaWiki\Cache\LinkBatchFactory;
4use MediaWiki\Config\ServiceOptions;
5use MediaWiki\Linker\LinkTarget;
6use MediaWiki\Page\PageIdentityValue;
7use MediaWiki\Revision\RevisionLookup;
8use MediaWiki\Revision\RevisionRecord;
9use MediaWiki\Tests\Unit\DummyServicesTrait;
10use MediaWiki\User\UserFactory;
11use MediaWiki\User\UserIdentity;
12use MediaWiki\User\UserIdentityValue;
13use PHPUnit\Framework\MockObject\MockObject;
14use Wikimedia\Rdbms\IDatabase;
15use Wikimedia\Rdbms\LBFactory;
16use Wikimedia\Rdbms\LoadBalancer;
17use Wikimedia\TestingAccessWrapper;
18
19/**
20 * @author Addshore
21 * @author DannyS712
22 *
23 * @covers WatchedItemStore
24 */
25class WatchedItemStoreUnitTest extends MediaWikiUnitTestCase {
26	use DummyServicesTrait;
27	use MockTitleTrait;
28
29	/**
30	 * @return MockObject|IDatabase
31	 */
32	private function getMockDb() {
33		return $this->createMock( IDatabase::class );
34	}
35
36	/**
37	 * @param IDatabase $mockDb
38	 * @param string|null $expectedConnectionType
39	 * @return MockObject|LoadBalancer
40	 */
41	private function getMockLoadBalancer(
42		$mockDb,
43		$expectedConnectionType = null
44	) {
45		$mock = $this->createMock( LoadBalancer::class );
46		if ( $expectedConnectionType !== null ) {
47			$mock->method( 'getConnectionRef' )
48				->with( $expectedConnectionType )
49				->willReturn( $mockDb );
50		} else {
51			$mock->method( 'getConnectionRef' )
52				->willReturn( $mockDb );
53		}
54		return $mock;
55	}
56
57	/**
58	 * @param IDatabase $mockDb
59	 * @param string|null $expectedConnectionType
60	 * @return MockObject|LBFactory
61	 */
62	private function getMockLBFactory(
63		$mockDb,
64		$expectedConnectionType = null
65	) {
66		$loadBalancer = $this->getMockLoadBalancer( $mockDb, $expectedConnectionType );
67		$mock = $this->createMock( LBFactory::class );
68		$mock->method( 'getMainLB' )
69			->willReturn( $loadBalancer );
70		return $mock;
71	}
72
73	/**
74	 * The job queue is used in three different places - two "push" calls, and a
75	 * "lazyPush" call - we don't test any of the "push" calls, so the callback
76	 * can just run the job, but we do test the "lazyPush" call, and so the test
77	 * that is using this may want to do something other than just run the job, since
78	 * for ActivityUpdateJob instances this results in using global functions, which we
79	 * cannot do in this unit test
80	 *
81	 * @param bool $mockLazyPush whether to add mock behavior for "lazyPush"
82	 * @return MockObject|JobQueueGroup
83	 */
84	private function getMockJobQueueGroup( $mockLazyPush = true ) {
85		$mock = $this->createMock( JobQueueGroup::class );
86		$mock->method( 'push' )
87			->willReturnCallback( static function ( Job $job ) {
88				$job->run();
89			} );
90		if ( $mockLazyPush ) {
91			$mock->method( 'lazyPush' )
92				->willReturnCallback( static function ( Job $job ) {
93					$job->run();
94				} );
95		}
96		return $mock;
97	}
98
99	/**
100	 * @return MockObject|HashBagOStuff
101	 */
102	private function getMockCache() {
103		$mock = $this->getMockBuilder( HashBagOStuff::class )
104			->disableOriginalConstructor()
105			->onlyMethods( [ 'get', 'set', 'delete', 'makeKey' ] )
106			->getMock();
107		$mock->method( 'makeKey' )
108			->willReturnCallback( static function ( ...$args ) {
109				return implode( ':', $args );
110			} );
111		return $mock;
112	}
113
114	/**
115	 * No methods may be called except provided callbacks, if any.
116	 *
117	 * @param array $callbacks Keys are method names, values are callbacks
118	 * @param array $counts Keys are method names, values are expected number of times to be called
119	 *   (default is any number is okay)
120	 * @return MockObject|RevisionLookup
121	 */
122	private function getMockRevisionLookup(
123		array $callbacks = [],
124		array $counts = []
125	): RevisionLookup {
126		$mock = $this->createMock( RevisionLookup::class );
127		foreach ( $callbacks as $method => $callback ) {
128			$count = isset( $counts[$method] ) ? $this->exactly( $counts[$method] ) : $this->any();
129			$mock->expects( $count )
130				->method( $method )
131				->willReturnCallback( $callbacks[$method] );
132		}
133		$mock->expects( $this->never() )
134			->method( $this->anythingBut( ...array_keys( $callbacks ) ) );
135		return $mock;
136	}
137
138	/**
139	 * @param IDatabase $mockDb
140	 * @return LinkBatchFactory
141	 */
142	private function getMockLinkBatchFactory( $mockDb ) {
143		return new LinkBatchFactory(
144			$this->createMock( LinkCache::class ),
145			$this->createMock( TitleFormatter::class ),
146			$this->createMock( Language::class ),
147			$this->createMock( GenderCache::class ),
148			$this->getMockLoadBalancer( $mockDb )
149		);
150	}
151
152	/**
153	 * @param User[] $users
154	 * @return MockObject|UserFactory
155	 */
156	private function getUserFactory( array $users = [] ) {
157		// UserFactory is only needed for newFromUserIdentity. Create a mock User object
158		// based on the UserIdentity. Used for WatchedItemStore::resetNotificationTimestamp
159		// which needs full User objects for a hook. Mock users returned have the same
160		// name and id, and pass User::equals() comparison with the UserIdentity they were
161		// created from.
162		$userFactory = $this->createNoOpMock( UserFactory::class, [ 'newFromUserIdentity' ] );
163		$userFactory->method( 'newFromUserIdentity' )->willReturnCallback(
164			function ( $userIdentity ) {
165				// Like real UserFactory, return $userIdentity if its a User
166				if ( $userIdentity instanceof User ) {
167					return $userIdentity;
168				}
169
170				$user = $this->createMock( User::class );
171				$user->method( 'getId' )->willReturn( $userIdentity->getId() );
172				$user->method( 'getName' )->willReturn( $userIdentity->getName() );
173				$user->method( 'equals' )->willReturnCallback(
174					static function ( UserIdentity $otherUser ) use ( $userIdentity ) {
175						// $user's name is the same as $userIdentity's
176						return $otherUser->getName() === $userIdentity->getName();
177					}
178				);
179				return $user;
180			}
181		);
182		return $userFactory;
183	}
184
185	/**
186	 * @param LinkTarget|PageIdentity|null $target
187	 * @param Title|null $title
188	 * @return MockObject|TitleFactory
189	 */
190	private function getTitleFactory( $target = null, $title = null ) {
191		// TitleFactory only needed for castFromLinkTarget or castFromPageIdentity - if this is
192		// called with a link target or page identity and a title, the mock expects the function
193		// invocation and returns the title, otherwise the mock expects never to be called.
194		// If no title is provided here, we create a placeholder mock that passes the ->equals()
195		// check, and thats it
196		$titleFactory = $this->createNoOpMock(
197			TitleFactory::class,
198			[
199				'castFromLinkTarget',
200				'castFromPageIdentity'
201			]
202		);
203		if ( $target !== null ) {
204			if ( $title === null ) {
205				$title = $this->makeMockTitle(
206					$target->getDBkey(),
207					[
208						'namespace' => $target->getNamespace()
209					]
210				);
211			}
212			$title->method( 'equals' )
213				->with( $target )
214				->willReturn( true );
215			if ( $target instanceof LinkTarget ) {
216				$titleFactory->method( 'castFromLinkTarget' )
217					->with( $target )
218					->willReturn( $title );
219				$titleFactory->expects( $this->never() )->method( 'castFromPageIdentity' );
220			} else {
221				$titleFactory->method( 'castFromPageIdentity' )
222					->with( $target )
223					->willReturn( $title );
224				$titleFactory->expects( $this->never() )->method( 'castFromLinkTarget' );
225			}
226		} else {
227			$titleFactory->expects( $this->never() )->method( 'castFromLinkTarget' );
228			$titleFactory->expects( $this->never() )->method( 'castFromPageIdentity' );
229		}
230		return $titleFactory;
231	}
232
233	/**
234	 * @param array $mocks Associative array providing mocks to use when constructing the
235	 *   WatchedItemStore. Anything not provided will fall back to a default. Valid keys:
236	 *     * lbFactory
237	 *     * db
238	 *     * queueGroup
239	 *     * cache
240	 *     * readOnlyMode
241	 *     * nsInfo
242	 *     * revisionLookup
243	 *     * userFactory
244	 *     * titleFactory
245	 *     * expiryEnabled
246	 *     * maxExpiryDuration
247	 *     * watchlistPurgeRate
248	 * @return WatchedItemStore
249	 */
250	private function newWatchedItemStore( array $mocks = [] ): WatchedItemStore {
251		$options = new ServiceOptions( WatchedItemStore::CONSTRUCTOR_OPTIONS, [
252			'UpdateRowsPerQuery' => 1000,
253			'WatchlistExpiry' => $mocks['expiryEnabled'] ?? true,
254			'WatchlistExpiryMaxDuration' => $mocks['maxExpiryDuration'] ?? null,
255			'WatchlistPurgeRate' => $mocks['watchlistPurgeRate'] ?? 0.1,
256		] );
257
258		$db = $mocks['db'] ?? $this->getMockDb();
259
260		// If we don't use a manual mock for something specific, get a full
261		// NamespaceInfo service from DummyServicesTrait::getDummyNamespaceInfo
262		$nsInfo = $mocks['nsInfo'] ?? $this->getDummyNamespaceInfo();
263
264		return new WatchedItemStore(
265			$options,
266			$mocks['lbFactory'] ??
267				$this->getMockLBFactory( $db ),
268			$mocks['queueGroup'] ?? $this->getMockJobQueueGroup(),
269			new HashBagOStuff(),
270			$mocks['cache'] ?? $this->getMockCache(),
271			$mocks['readOnlyMode'] ?? $this->getDummyReadOnlyMode( false ),
272			$nsInfo,
273			$mocks['revisionLookup'] ?? $this->getMockRevisionLookup(),
274			$this->createHookContainer(),
275			$this->getMockLinkBatchFactory( $db ),
276			$this->getUserFactory(),
277			$mocks['titleFactory'] ?? $this->getTitleFactory()
278		);
279	}
280
281	public function testClearWatchedItems() {
282		$user = new UserIdentityValue( 7, 'MockUser' );
283
284		$mockDb = $this->getMockDb();
285		$mockDb->expects( $this->once() )
286			->method( 'selectField' )
287			->with(
288				[ 'watchlist' ],
289				'COUNT(*)',
290				[
291					'wl_user' => $user->getId(),
292				],
293				$this->isType( 'string' )
294			)
295			->willReturn( 12 );
296		$mockDb->expects( $this->once() )
297			->method( 'delete' )
298			->with(
299				'watchlist',
300				[ 'wl_user' => 7 ],
301				$this->isType( 'string' )
302			);
303
304		$mockCache = $this->getMockCache();
305		$mockCache->expects( $this->never() )->method( 'get' );
306		$mockCache->expects( $this->never() )->method( 'set' );
307		$mockCache->expects( $this->once() )
308			->method( 'delete' )
309			->with( 'RM-KEY' );
310
311		$store = $this->newWatchedItemStore( [
312			'db' => $mockDb,
313			'cache' => $mockCache,
314			'expiryEnabled' => false,
315		] );
316		TestingAccessWrapper::newFromObject( $store )
317			->cacheIndex = [ 0 => [ 'F' => [ 7 => 'RM-KEY', 9 => 'KEEP-KEY' ] ] ];
318
319		$this->assertTrue( $store->clearUserWatchedItems( $user ) );
320	}
321
322	public function testClearWatchedItems_watchlistExpiry() {
323		$user = new UserIdentityValue( 7, 'MockUser' );
324
325		$mockDb = $this->getMockDb();
326		// Select watchlist IDs.
327		$mockDb->expects( $this->once() )
328			->method( 'selectFieldValues' )
329			->willReturn( [ 1, 2 ] );
330
331		$mockDb->expects( $this->exactly( 2 ) )
332			->method( 'delete' )
333			->withConsecutive(
334				[
335					'watchlist',
336					[ 'wl_id' => [ 1, 2 ] ]
337				],
338				[
339					'watchlist_expiry',
340					[ 'we_item' => [ 1, 2 ] ]
341				]
342			);
343
344		$mockCache = $this->getMockCache();
345		$mockCache->expects( $this->never() )->method( 'get' );
346		$mockCache->expects( $this->never() )->method( 'set' );
347		$mockCache->expects( $this->once() )
348			->method( 'delete' )
349			->with( 'RM-KEY' );
350
351		$store = $this->newWatchedItemStore( [
352			'db' => $mockDb,
353			'cache' => $mockCache,
354			'expiryEnabled' => true,
355		] );
356		TestingAccessWrapper::newFromObject( $store )
357			->cacheIndex = [ 0 => [ 'F' => [ 7 => 'RM-KEY', 9 => 'KEEP-KEY' ] ] ];
358
359		$this->assertTrue( $store->clearUserWatchedItems( $user ) );
360	}
361
362	public function testClearWatchedItems_tooManyItemsWatched() {
363		$user = new UserIdentityValue( 7, 'MockUser' );
364
365		$mockDb = $this->getMockDb();
366		$mockDb->expects( $this->once() )
367			->method( 'selectField' )
368			->with(
369				[ 'watchlist' ],
370				'COUNT(*)',
371				[
372					'wl_user' => $user->getId(),
373				],
374				$this->isType( 'string' )
375			)
376			->willReturn( 99999 );
377
378		$mockCache = $this->getMockCache();
379		$mockCache->expects( $this->never() )->method( 'get' );
380		$mockCache->expects( $this->never() )->method( 'set' );
381		$mockCache->expects( $this->never() )->method( 'delete' );
382
383		$store = $this->newWatchedItemStore( [
384			'db' => $mockDb,
385			'cache' => $mockCache,
386			'expiryEnabled' => false,
387		] );
388
389		$this->assertFalse( $store->clearUserWatchedItems( $user ) );
390	}
391
392	public function testCountWatchedItems() {
393		$user = new UserIdentityValue( 1, 'MockUser' );
394
395		$mockDb = $this->getMockDb();
396		$mockDb->expects( $this->once() )
397			->method( 'addQuotes' )
398			->willReturn( '20200101000000' );
399		$mockDb->expects( $this->once() )
400			->method( 'selectField' )
401			->with(
402				[ 'watchlist', 'watchlist_expiry' ],
403				'COUNT(*)',
404				[
405					'wl_user' => $user->getId(),
406					'we_expiry IS NULL OR we_expiry > 20200101000000'
407				],
408				$this->isType( 'string' )
409			)
410			->willReturn( '12' );
411
412		$mockCache = $this->getMockCache();
413		$mockCache->expects( $this->never() )->method( 'get' );
414		$mockCache->expects( $this->never() )->method( 'set' );
415		$mockCache->expects( $this->never() )->method( 'delete' );
416
417		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
418
419		$this->assertEquals( 12, $store->countWatchedItems( $user ) );
420	}
421
422	public function provideTestPageFactory() {
423		yield [ static function ( $pageId, $namespace, $dbKey ) {
424			return new TitleValue( $namespace, $dbKey );
425		} ];
426		yield [ static function ( $pageId, $namespace, $dbKey ) {
427			return new PageIdentityValue( $pageId, $namespace, $dbKey, PageIdentityValue::LOCAL );
428		} ];
429		yield [ function ( $pageId, $namespace, $dbKey ) {
430			return $this->makeMockTitle( $dbKey, [
431				'id' => $pageId,
432				'namespace' => $namespace
433			] );
434		} ];
435	}
436
437	/**
438	 * @dataProvider provideTestPageFactory
439	 */
440	public function testCountWatchers( $testPageFactory ) {
441		$titleValue = $testPageFactory( 100, 0, 'SomeDbKey' );
442
443		$mockDb = $this->getMockDb();
444		$mockDb->expects( $this->once() )
445			->method( 'addQuotes' )
446			->willReturn( '20200101000000' );
447		$mockDb->expects( $this->once() )
448			->method( 'selectField' )
449			->with(
450				[ 'watchlist', 'watchlist_expiry' ],
451				'COUNT(*)',
452				[
453					'wl_namespace' => $titleValue->getNamespace(),
454					'wl_title' => $titleValue->getDBkey(),
455					'we_expiry IS NULL OR we_expiry > 20200101000000'
456				],
457				$this->isType( 'string' )
458			)
459			->willReturn( '7' );
460
461		$mockCache = $this->getMockCache();
462		$mockCache->expects( $this->never() )->method( 'get' );
463		$mockCache->expects( $this->never() )->method( 'set' );
464		$mockCache->expects( $this->never() )->method( 'delete' );
465
466		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
467
468		$this->assertEquals( 7, $store->countWatchers( $titleValue ) );
469	}
470
471	/**
472	 * @dataProvider provideTestPageFactory
473	 */
474	public function testCountWatchersMultiple( $testPageFactory ) {
475		$titleValues = [
476			$testPageFactory( 100, 0, 'SomeDbKey' ),
477			$testPageFactory( 101, 0, 'OtherDbKey' ),
478			$testPageFactory( 102, 1, 'AnotherDbKey' ),
479		];
480
481		$mockDb = $this->getMockDb();
482
483		$dbResult = [
484			(object)[ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ],
485			(object)[ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ],
486			(object)[ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ],
487		];
488		$mockDb->expects( $this->once() )
489			->method( 'makeWhereFrom2d' )
490			->with(
491				[ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
492				$this->isType( 'string' ),
493				$this->isType( 'string' )
494			)
495			->willReturn( 'makeWhereFrom2d return value' );
496
497		$mockDb->expects( $this->once() )
498			->method( 'addQuotes' )
499			->willReturn( '20200101000000' );
500
501		$mockDb->expects( $this->once() )
502			->method( 'select' )
503			->with(
504				[ 'watchlist', 'watchlist_expiry' ],
505				[ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
506				[
507					'makeWhereFrom2d return value',
508					'we_expiry IS NULL OR we_expiry > 20200101000000'
509				],
510				$this->isType( 'string' ),
511				[
512					'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
513				]
514			)
515			->willReturn( $dbResult );
516
517		$mockCache = $this->getMockCache();
518		$mockCache->expects( $this->never() )->method( 'get' );
519		$mockCache->expects( $this->never() )->method( 'set' );
520		$mockCache->expects( $this->never() )->method( 'delete' );
521
522		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
523
524		$expected = [
525			0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
526			1 => [ 'AnotherDbKey' => 500 ],
527		];
528		$this->assertEquals( $expected, $store->countWatchersMultiple( $titleValues ) );
529	}
530
531	public function provideIntWithDbUnsafeVersion() {
532		return [
533			[ 50 ],
534			[ "50; DROP TABLE watchlist;\n--" ],
535		];
536	}
537
538	public function provideTestPageFactoryAndIntWithDbUnsafeVersion() {
539		foreach ( $this->provideIntWithDBUnsafeVersion() as $dbint ) {
540			foreach ( $this->provideTestPageFactory() as $testPageFactory ) {
541				yield [ $dbint[0], $testPageFactory[0] ];
542			}
543		}
544	}
545
546	/**
547	 * @dataProvider provideTestPageFactoryAndIntWithDbUnsafeVersion
548	 */
549	public function testCountWatchersMultiple_withMinimumWatchers( $minWatchers, $testPageFactory ) {
550		$titleValues = [
551			$testPageFactory( 100, 0, 'SomeDbKey' ),
552			$testPageFactory( 101, 0, 'OtherDbKey' ),
553			$testPageFactory( 102, 1, 'AnotherDbKey' ),
554		];
555
556		$mockDb = $this->getMockDb();
557
558		$dbResult = [
559			(object)[ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ],
560			(object)[ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ],
561			(object)[ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ],
562		];
563
564		$mockDb->expects( $this->once() )
565			->method( 'makeWhereFrom2d' )
566			->with(
567				[ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
568				$this->isType( 'string' ),
569				$this->isType( 'string' )
570			)
571			->willReturn( 'makeWhereFrom2d return value' );
572
573		$mockDb->expects( $this->once() )
574			->method( 'addQuotes' )
575			->willReturn( '20200101000000' );
576
577		$mockDb->expects( $this->once() )
578			->method( 'select' )
579			->with(
580				[ 'watchlist', 'watchlist_expiry' ],
581				[ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
582				[
583					'makeWhereFrom2d return value',
584					'we_expiry IS NULL OR we_expiry > 20200101000000'
585				],
586				$this->isType( 'string' ),
587				[
588					'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
589					'HAVING' => 'COUNT(*) >= 50',
590				]
591			)
592			->willReturn( $dbResult );
593
594		$mockCache = $this->getMockCache();
595		$mockCache->expects( $this->never() )->method( 'get' );
596		$mockCache->expects( $this->never() )->method( 'set' );
597		$mockCache->expects( $this->never() )->method( 'delete' );
598
599		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
600
601		$expected = [
602			0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
603			1 => [ 'AnotherDbKey' => 500 ],
604		];
605		$this->assertEquals(
606			$expected,
607			$store->countWatchersMultiple( $titleValues, [ 'minimumWatchers' => $minWatchers ] )
608		);
609	}
610
611	/**
612	 * @dataProvider provideTestPageFactory
613	 */
614	public function testCountVisitingWatchers( $testPageFactory ) {
615		$titleValue = $testPageFactory( 100, 0, 'SomeDbKey' );
616
617		$mockDb = $this->getMockDb();
618
619		$mockDb->expects( $this->once() )
620			->method( 'selectField' )
621			->with(
622				[ 'watchlist', 'watchlist_expiry' ],
623				'COUNT(*)',
624				[
625					'wl_namespace' => $titleValue->getNamespace(),
626					'wl_title' => $titleValue->getDBkey(),
627					'wl_notificationtimestamp >= \'TS111TS\' OR wl_notificationtimestamp IS NULL',
628					'we_expiry IS NULL OR we_expiry > \'20200101000000\''
629				],
630				$this->isType( 'string' )
631			)
632			->willReturn( '7' );
633
634		$mockDb->expects( $this->exactly( 2 ) )
635			->method( 'addQuotes' )
636			->willReturnCallback( static function ( $value ) {
637				return "'$value'";
638			} );
639
640		$mockDb->expects( $this->exactly( 2 ) )
641			->method( 'timestamp' )
642			->willReturnCallback( static function ( $value ) {
643				if ( $value === 0 ) {
644					return '20200101000000';
645				}
646				return 'TS' . $value . 'TS';
647			} );
648
649		$mockCache = $this->getMockCache();
650		$mockCache->expects( $this->never() )->method( 'set' );
651		$mockCache->expects( $this->never() )->method( 'get' );
652		$mockCache->expects( $this->never() )->method( 'delete' );
653
654		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
655
656		$this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) );
657	}
658
659	/**
660	 * @dataProvider provideTestPageFactory
661	 */
662	public function testCountVisitingWatchersMultiple( $testPageFactory ) {
663		$titleValuesWithThresholds = [
664			[ $testPageFactory( 100, 0, 'SomeDbKey' ), '111' ],
665			[ $testPageFactory( 101, 0, 'OtherDbKey' ), '111' ],
666			[ $testPageFactory( 102, 1, 'AnotherDbKey' ), '123' ],
667		];
668
669		$dbResult = [
670			(object)[ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ],
671			(object)[ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ],
672			(object)[ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ],
673		];
674		$mockDb = $this->getMockDb();
675		$mockDb->expects( $this->exactly( 2 * 3 + 1 ) )
676			->method( 'addQuotes' )
677			->willReturnCallback( static function ( $value ) {
678				return "'$value'";
679			} );
680
681		$mockDb->expects( $this->exactly( 4 ) )
682			->method( 'timestamp' )
683			->willReturnCallback( static function ( $value ) {
684				if ( $value === 0 ) {
685					return '20200101000000';
686				}
687				return 'TS' . $value . 'TS';
688			} );
689
690		$mockDb->method( 'makeList' )
691			->with(
692				$this->isType( 'array' ),
693				$this->isType( 'int' )
694			)
695			->willReturnCallback( static function ( $a, $conj ) {
696				$sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
697				return implode( $sqlConj, array_map( static function ( $s ) {
698					return '(' . $s . ')';
699				}, $a
700				) );
701			} );
702		$mockDb->expects( $this->never() )
703			->method( 'makeWhereFrom2d' );
704
705		$expectedCond =
706			'((wl_namespace = 0) AND (' .
707			"(((wl_title = 'SomeDbKey') AND (" .
708			"(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
709			')) OR (' .
710			"(wl_title = 'OtherDbKey') AND (" .
711			"(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
712			'))))' .
713			') OR ((wl_namespace = 1) AND (' .
714			"(((wl_title = 'AnotherDbKey') AND (" .
715			"(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
716			')))))';
717		$mockDb->expects( $this->once() )
718			->method( 'select' )
719			->with(
720				[ 'watchlist', 'watchlist_expiry' ],
721				[ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
722				[
723					$expectedCond,
724					'we_expiry IS NULL OR we_expiry > \'20200101000000\''
725				],
726				$this->isType( 'string' ),
727				[
728					'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
729				]
730			)
731			->willReturn( $dbResult );
732
733		$mockCache = $this->getMockCache();
734		$mockCache->expects( $this->never() )->method( 'get' );
735		$mockCache->expects( $this->never() )->method( 'set' );
736		$mockCache->expects( $this->never() )->method( 'delete' );
737
738		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
739
740		$expected = [
741			0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
742			1 => [ 'AnotherDbKey' => 500 ],
743		];
744		$this->assertEquals(
745			$expected,
746			$store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
747		);
748	}
749
750	/**
751	 * @dataProvider provideTestPageFactory
752	 */
753	public function testCountVisitingWatchersMultiple_withMissingTargets( $testPageFactory ) {
754		$titleValuesWithThresholds = [
755			[ $testPageFactory( 100, 0, 'SomeDbKey' ), '111' ],
756			[ $testPageFactory( 101, 0, 'OtherDbKey' ), '111' ],
757			[ $testPageFactory( 102, 1, 'AnotherDbKey' ), '123' ],
758			[ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ],
759			[ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ],
760		];
761
762		$dbResult = [
763			(object)[ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ],
764			(object)[ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ],
765			(object)[ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ],
766			(object)[ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => '0', 'watchers' => '100' ],
767			(object)[ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => '0', 'watchers' => '200' ],
768		];
769		$mockDb = $this->getMockDb();
770		$mockDb->expects( $this->exactly( 2 * 3 ) )
771			->method( 'addQuotes' )
772			->willReturnCallback( static function ( $value ) {
773				return "'$value'";
774			} );
775		$mockDb->expects( $this->exactly( 3 ) )
776			->method( 'timestamp' )
777			->willReturnCallback( static function ( $value ) {
778				return 'TS' . $value . 'TS';
779			} );
780		$mockDb->method( 'makeList' )
781			->with(
782				$this->isType( 'array' ),
783				$this->isType( 'int' )
784			)
785			->willReturnCallback( static function ( $a, $conj ) {
786				$sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
787				return implode( $sqlConj, array_map( static function ( $s ) {
788					return '(' . $s . ')';
789				}, $a
790				) );
791			} );
792		$mockDb->expects( $this->once() )
793			->method( 'makeWhereFrom2d' )
794			->with(
795				[ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ],
796				$this->isType( 'string' ),
797				$this->isType( 'string' )
798			)
799			->willReturn( 'makeWhereFrom2d return value' );
800
801		$expectedCond =
802			'((wl_namespace = 0) AND (' .
803			"(((wl_title = 'SomeDbKey') AND (" .
804			"(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
805			')) OR (' .
806			"(wl_title = 'OtherDbKey') AND (" .
807			"(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
808			'))))' .
809			') OR ((wl_namespace = 1) AND (' .
810			"(((wl_title = 'AnotherDbKey') AND (" .
811			"(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
812			'))))' .
813			') OR ' .
814			'(makeWhereFrom2d return value)';
815		$mockDb->expects( $this->once() )
816			->method( 'select' )
817			->with(
818				[ 'watchlist' ],
819				[ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
820				[ $expectedCond ],
821				$this->isType( 'string' ),
822				[
823					'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
824				]
825			)
826			->willReturn( $dbResult );
827
828		$mockCache = $this->getMockCache();
829		$mockCache->expects( $this->never() )->method( 'get' );
830		$mockCache->expects( $this->never() )->method( 'set' );
831		$mockCache->expects( $this->never() )->method( 'delete' );
832
833		$store = $this->newWatchedItemStore( [
834				'db' => $mockDb,
835				'cache' => $mockCache,
836				'expiryEnabled' => false
837			] );
838
839		$expected = [
840			0 => [
841				'SomeDbKey' => 100, 'OtherDbKey' => 300,
842				'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200
843			],
844			1 => [ 'AnotherDbKey' => 500 ],
845		];
846		$this->assertEquals(
847			$expected,
848			$store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
849		);
850	}
851
852	/**
853	 * @dataProvider provideTestPageFactoryAndIntWithDbUnsafeVersion
854	 */
855	public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers, $testPageFactory ) {
856		$titleValuesWithThresholds = [
857			[ $testPageFactory( 100, 0, 'SomeDbKey' ), '111' ],
858			[ $testPageFactory( 101, 0, 'OtherDbKey' ), '111' ],
859			[ $testPageFactory( 102, 1, 'AnotherDbKey' ), '123' ],
860		];
861
862		$mockDb = $this->getMockDb();
863		$mockDb->method( 'makeList' )
864			->willReturn( 'makeList return value' );
865		$mockDb->expects( $this->once() )
866			->method( 'select' )
867			->with(
868				[ 'watchlist' ],
869				[ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
870				[ 'makeList return value' ],
871				$this->isType( 'string' ),
872				[
873					'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
874					'HAVING' => 'COUNT(*) >= 50',
875				]
876			)
877			->willReturn( [] );
878
879		$mockCache = $this->getMockCache();
880		$mockCache->expects( $this->never() )->method( 'get' );
881		$mockCache->expects( $this->never() )->method( 'set' );
882		$mockCache->expects( $this->never() )->method( 'delete' );
883
884		$store = $this->newWatchedItemStore( [
885			'db' => $mockDb,
886			'cache' => $mockCache,
887			'expiryEnabled' => false,
888		] );
889
890		$expected = [
891			0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ],
892			1 => [ 'AnotherDbKey' => 0 ],
893		];
894		$this->assertEquals(
895			$expected,
896			$store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers )
897		);
898	}
899
900	public function testCountUnreadNotifications() {
901		$user = new UserIdentityValue( 1, 'MockUser' );
902
903		$mockDb = $this->getMockDb();
904		$mockDb->expects( $this->once() )
905			->method( 'selectRowCount' )
906			->with(
907				'watchlist',
908				'1',
909				[
910					"wl_notificationtimestamp IS NOT NULL",
911					'wl_user' => 1,
912				],
913				$this->isType( 'string' )
914			)
915			->willReturn( '9' );
916
917		$mockCache = $this->getMockCache();
918		$mockCache->expects( $this->never() )->method( 'set' );
919		$mockCache->expects( $this->never() )->method( 'get' );
920		$mockCache->expects( $this->never() )->method( 'delete' );
921
922		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
923
924		$this->assertEquals( 9, $store->countUnreadNotifications( $user ) );
925	}
926
927	/**
928	 * @dataProvider provideIntWithDbUnsafeVersion
929	 */
930	public function testCountUnreadNotifications_withUnreadLimit_overLimit( $limit ) {
931		$user = new UserIdentityValue( 1, 'MockUser' );
932
933		$mockDb = $this->getMockDb();
934		$mockDb->expects( $this->once() )
935			->method( 'selectRowCount' )
936			->with(
937				'watchlist',
938				'1',
939				[
940					"wl_notificationtimestamp IS NOT NULL",
941					'wl_user' => 1,
942				],
943				$this->isType( 'string' ),
944				[ 'LIMIT' => 50 ]
945			)
946			->willReturn( '50' );
947
948		$mockCache = $this->getMockCache();
949		$mockCache->expects( $this->never() )->method( 'set' );
950		$mockCache->expects( $this->never() )->method( 'get' );
951		$mockCache->expects( $this->never() )->method( 'delete' );
952
953		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
954
955		$this->assertSame(
956			true,
957			$store->countUnreadNotifications( $user, $limit )
958		);
959	}
960
961	/**
962	 * @dataProvider provideIntWithDbUnsafeVersion
963	 */
964	public function testCountUnreadNotifications_withUnreadLimit_underLimit( $limit ) {
965		$user = new UserIdentityValue( 1, 'MockUser' );
966
967		$mockDb = $this->getMockDb();
968		$mockDb->expects( $this->once() )
969			->method( 'selectRowCount' )
970			->with(
971				'watchlist',
972				'1',
973				[
974					"wl_notificationtimestamp IS NOT NULL",
975					'wl_user' => 1,
976				],
977				$this->isType( 'string' ),
978				[ 'LIMIT' => 50 ]
979			)
980			->willReturn( '9' );
981
982		$mockCache = $this->getMockCache();
983		$mockCache->expects( $this->never() )->method( 'set' );
984		$mockCache->expects( $this->never() )->method( 'get' );
985		$mockCache->expects( $this->never() )->method( 'delete' );
986
987		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
988
989		$this->assertEquals(
990			9,
991			$store->countUnreadNotifications( $user, $limit )
992		);
993	}
994
995	/**
996	 * @dataProvider provideTestPageFactory
997	 */
998	public function testDuplicateEntry_nothingToDuplicate( $testPageFactory ) {
999		$mockDb = $this->getMockDb();
1000		$mockDb->expects( $this->once() )
1001			->method( 'select' )
1002			->with(
1003				[ 'watchlist', 'watchlist_expiry' ],
1004				[ 'wl_user', 'wl_notificationtimestamp', 'we_expiry' ],
1005				[
1006					'wl_namespace' => 0,
1007					'wl_title' => 'Old_Title',
1008				],
1009				'WatchedItemStore::fetchWatchedItemsForPage',
1010				[ 'FOR UPDATE' ],
1011				[ 'watchlist_expiry' => [ 'LEFT JOIN', [ 'wl_id = we_item' ] ] ]
1012			)
1013			->willReturn( new FakeResultWrapper( [] ) );
1014
1015		$store = $this->newWatchedItemStore( [ 'db' => $mockDb ] );
1016
1017		$store->duplicateEntry(
1018			$testPageFactory( 100, 0, 'Old_Title' ),
1019			$testPageFactory( 101, 0, 'New_Title' )
1020		);
1021	}
1022
1023	/**
1024	 * @dataProvider provideTestPageFactory
1025	 */
1026	public function testDuplicateEntry_somethingToDuplicate( $testPageFactory ) {
1027		$fakeRows = [
1028			(object)[
1029				'wl_user' => '1',
1030				'wl_notificationtimestamp' => '20151212010101',
1031			],
1032			(object)[
1033				'wl_user' => '2',
1034				'wl_notificationtimestamp' => null,
1035			],
1036		];
1037
1038		$mockDb = $this->getMockDb();
1039		$mockDb->expects( $this->at( 0 ) )
1040			->method( 'select' )
1041			->with(
1042				[ 'watchlist' ],
1043				[ 'wl_user', 'wl_notificationtimestamp' ],
1044				[
1045					'wl_namespace' => 0,
1046					'wl_title' => 'Old_Title',
1047				]
1048			)
1049			->willReturn( new FakeResultWrapper( $fakeRows ) );
1050		$mockDb->expects( $this->at( 1 ) )
1051			->method( 'replace' )
1052			->with(
1053				'watchlist',
1054				[ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1055				[
1056					[
1057						'wl_user' => 1,
1058						'wl_namespace' => 0,
1059						'wl_title' => 'New_Title',
1060						'wl_notificationtimestamp' => '20151212010101',
1061					],
1062					[
1063						'wl_user' => 2,
1064						'wl_namespace' => 0,
1065						'wl_title' => 'New_Title',
1066						'wl_notificationtimestamp' => null,
1067					],
1068				],
1069				$this->isType( 'string' )
1070			);
1071
1072		$mockCache = $this->getMockCache();
1073		$mockCache->expects( $this->never() )->method( 'get' );
1074		$mockCache->expects( $this->never() )->method( 'delete' );
1075
1076		$store = $this->newWatchedItemStore( [
1077			'db' => $mockDb,
1078			'cache' => $mockCache,
1079			'expiryEnabled' => false,
1080		] );
1081
1082		$store->duplicateEntry(
1083			$testPageFactory( 100, 0, 'Old_Title' ),
1084			$testPageFactory( 101, 0, 'New_Title' )
1085		);
1086	}
1087
1088	/**
1089	 * @dataProvider provideTestPageFactory
1090	 */
1091	public function testDuplicateAllAssociatedEntries_nothingToDuplicate( $testPageFactory ) {
1092		$mockDb = $this->getMockDb();
1093		$mockDb->expects( $this->at( 0 ) )
1094			->method( 'select' )
1095			->with(
1096				[ 'watchlist', 'watchlist_expiry' ],
1097				[ 'wl_user', 'wl_notificationtimestamp', 'we_expiry' ],
1098				[
1099					'wl_namespace' => 0,
1100					'wl_title' => 'Old_Title',
1101				]
1102			)
1103			->willReturn( new FakeResultWrapper( [] ) );
1104		$mockDb->expects( $this->at( 1 ) )
1105			->method( 'select' )
1106			->with(
1107				[ 'watchlist', 'watchlist_expiry' ],
1108				[ 'wl_user', 'wl_notificationtimestamp', 'we_expiry' ],
1109				[
1110					'wl_namespace' => 1,
1111					'wl_title' => 'Old_Title',
1112				]
1113			)
1114			->willReturn( new FakeResultWrapper( [] ) );
1115
1116		$mockCache = $this->getMockCache();
1117		$mockCache->expects( $this->never() )->method( 'get' );
1118		$mockCache->expects( $this->never() )->method( 'delete' );
1119
1120		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1121
1122		$store->duplicateAllAssociatedEntries(
1123			$testPageFactory( 100, 0, 'Old_Title' ),
1124			$testPageFactory( 101, 0, 'New_Title' )
1125		);
1126	}
1127
1128	public function provideLinkTargetPairs() {
1129		foreach ( $this->provideTestPageFactory() as $testPageFactoryArray ) {
1130			$testPageFactory = $testPageFactoryArray[0];
1131			yield [ $testPageFactory( 100, 0, 'Old_Title' ), $testPageFactory( 101, 0, 'New_Title' ) ];
1132		}
1133	}
1134
1135	/**
1136	 * @param LinkTarget|PageIdentity $oldTarget
1137	 * @param LinkTarget|PageIdentity $newTarget
1138	 * @dataProvider provideLinkTargetPairs
1139	 */
1140	public function testDuplicateAllAssociatedEntries_somethingToDuplicate(
1141		$oldTarget,
1142		$newTarget
1143	) {
1144		$fakeRows = [
1145			(object)[
1146				'wl_user' => '1',
1147				'wl_notificationtimestamp' => '20151212010101',
1148				'we_expiry' => null,
1149			],
1150		];
1151
1152		$mockDb = $this->getMockDb();
1153		$mockDb->expects( $this->at( 0 ) )
1154			->method( 'select' )
1155			->with(
1156				[ 'watchlist' ],
1157				[ 'wl_user', 'wl_notificationtimestamp' ],
1158				[
1159					'wl_namespace' => $oldTarget->getNamespace(),
1160					'wl_title' => $oldTarget->getDBkey(),
1161				]
1162			)
1163			->willReturn( new FakeResultWrapper( $fakeRows ) );
1164		$mockDb->expects( $this->at( 1 ) )
1165			->method( 'replace' )
1166			->with(
1167				'watchlist',
1168				[ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1169				[
1170					[
1171						'wl_user' => 1,
1172						'wl_namespace' => $newTarget->getNamespace(),
1173						'wl_title' => $newTarget->getDBkey(),
1174						'wl_notificationtimestamp' => '20151212010101',
1175					],
1176				],
1177				$this->isType( 'string' )
1178			);
1179		$mockDb->expects( $this->at( 2 ) )
1180			->method( 'select' )
1181			->with(
1182				[ 'watchlist' ],
1183				[ 'wl_user', 'wl_notificationtimestamp' ],
1184				[
1185					'wl_namespace' => $oldTarget->getNamespace() + 1,
1186					'wl_title' => $oldTarget->getDBkey(),
1187				]
1188			)
1189			->willReturn( new FakeResultWrapper( $fakeRows ) );
1190		$mockDb->expects( $this->at( 3 ) )
1191			->method( 'replace' )
1192			->with(
1193				'watchlist',
1194				[ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1195				[
1196					[
1197						'wl_user' => 1,
1198						'wl_namespace' => $newTarget->getNamespace() + 1,
1199						'wl_title' => $newTarget->getDBkey(),
1200						'wl_notificationtimestamp' => '20151212010101',
1201					],
1202				],
1203				$this->isType( 'string' )
1204			);
1205
1206		$mockCache = $this->getMockCache();
1207		$mockCache->expects( $this->never() )->method( 'get' );
1208		$mockCache->expects( $this->never() )->method( 'delete' );
1209
1210		$store = $this->newWatchedItemStore( [
1211			'db' => $mockDb,
1212			'cache' => $mockCache,
1213			'expiryEnabled' => false,
1214		] );
1215
1216		$store->duplicateAllAssociatedEntries(
1217			$oldTarget,
1218			$newTarget
1219		);
1220	}
1221
1222	/**
1223	 * @dataProvider provideTestPageFactory
1224	 */
1225	public function testAddWatch_nonAnonymousUser( $testPageFactory ) {
1226		$mockDb = $this->getMockDb();
1227		$mockDb->expects( $this->once() )
1228			->method( 'insert' )
1229			->with(
1230				'watchlist',
1231				[
1232					[
1233						'wl_user' => 1,
1234						'wl_namespace' => 0,
1235						'wl_title' => 'Some_Page',
1236						'wl_notificationtimestamp' => null,
1237					]
1238				]
1239			);
1240
1241		$mockCache = $this->getMockCache();
1242		$mockCache->expects( $this->once() )
1243			->method( 'delete' )
1244			->with( '0:Some_Page:1' );
1245
1246		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1247
1248		$store->addWatch(
1249			new UserIdentityValue( 1, 'MockUser' ),
1250			$testPageFactory( 100, 0, 'Some_Page' )
1251		);
1252	}
1253
1254	/**
1255	 * @dataProvider provideTestPageFactory
1256	 */
1257	public function testAddWatch_anonymousUser( $testPageFactory ) {
1258		$mockDb = $this->getMockDb();
1259		$mockDb->expects( $this->never() )
1260			->method( 'insert' );
1261
1262		$mockCache = $this->getMockCache();
1263		$mockCache->expects( $this->never() )
1264			->method( 'delete' );
1265
1266		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1267
1268		$store->addWatch(
1269			new UserIdentityValue( 0, 'AnonUser' ),
1270			$testPageFactory( 100, 0, 'Some_Page' )
1271		);
1272	}
1273
1274	/**
1275	 * @dataProvider provideTestPageFactory
1276	 */
1277	public function testAddWatchBatchForUser_readOnlyDBReturnsFalse( $testPageFactory ) {
1278		$store = $this->newWatchedItemStore(
1279			[ 'readOnlyMode' => $this->getDummyReadOnlyMode( true ) ]
1280		);
1281
1282		$this->assertFalse(
1283			$store->addWatchBatchForUser(
1284				new UserIdentityValue( 1, 'MockUser' ),
1285				[ $testPageFactory( 100, 0, 'Some_Page' ), $testPageFactory( 101, 1, 'Some_Page' ) ]
1286			)
1287		);
1288	}
1289
1290	/**
1291	 * @dataProvider provideTestPageFactory
1292	 */
1293	public function testAddWatchBatchForUser_nonAnonymousUser( $testPageFactory ) {
1294		$mockDb = $this->getMockDb();
1295		$mockDb->expects( $this->once() )
1296			->method( 'insert' )
1297			->with(
1298				'watchlist',
1299				[
1300					[
1301						'wl_user' => 1,
1302						'wl_namespace' => 0,
1303						'wl_title' => 'Some_Page',
1304						'wl_notificationtimestamp' => null,
1305					],
1306					[
1307						'wl_user' => 1,
1308						'wl_namespace' => 1,
1309						'wl_title' => 'Some_Page',
1310						'wl_notificationtimestamp' => null,
1311					]
1312				]
1313			);
1314
1315		$mockDb->expects( $this->once() )
1316			->method( 'affectedRows' )
1317			->willReturn( 2 );
1318
1319		$mockCache = $this->getMockCache();
1320		$mockCache->expects( $this->exactly( 2 ) )
1321			->method( 'delete' );
1322		$mockCache->expects( $this->at( 1 ) )
1323			->method( 'delete' )
1324			->with( '0:Some_Page:1' );
1325		$mockCache->expects( $this->at( 3 ) )
1326			->method( 'delete' )
1327			->with( '1:Some_Page:1' );
1328
1329		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1330
1331		$mockUser = new UserIdentityValue( 1, 'MockUser' );
1332
1333		$this->assertTrue(
1334			$store->addWatchBatchForUser(
1335				$mockUser,
1336				[ $testPageFactory( 100, 0, 'Some_Page' ), $testPageFactory( 101, 1, 'Some_Page' ) ]
1337			)
1338		);
1339	}
1340
1341	/**
1342	 * @dataProvider provideTestPageFactory
1343	 */
1344	public function testAddWatchBatchForUser_anonymousUsersAreSkipped( $testPageFactory ) {
1345		$mockDb = $this->getMockDb();
1346		$mockDb->expects( $this->never() )
1347			->method( 'insert' );
1348
1349		$mockCache = $this->getMockCache();
1350		$mockCache->expects( $this->never() )
1351			->method( 'delete' );
1352
1353		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1354
1355		$this->assertFalse(
1356			$store->addWatchBatchForUser(
1357				new UserIdentityValue( 0, 'AnonUser' ),
1358				[ $testPageFactory( 100, 0, 'Other_Page' ) ]
1359			)
1360		);
1361	}
1362
1363	public function testAddWatchBatchReturnsTrue_whenGivenEmptyList() {
1364		$user = new UserIdentityValue( 1, 'MockUser' );
1365		$mockDb = $this->getMockDb();
1366		$mockDb->expects( $this->never() )
1367			->method( 'insert' );
1368
1369		$mockCache = $this->getMockCache();
1370		$mockCache->expects( $this->never() )
1371			->method( 'delete' );
1372
1373		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1374
1375		$this->assertTrue(
1376			$store->addWatchBatchForUser( $user, [] )
1377		);
1378	}
1379
1380	/**
1381	 * @dataProvider provideTestPageFactory
1382	 */
1383	public function testLoadWatchedItem_existingItem( $testPageFactory ) {
1384		$mockDb = $this->getMockDb();
1385		$mockDb->expects( $this->once() )
1386			->method( 'addQuotes' )
1387			->willReturn( '20200101000000' );
1388		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
1389		$mockDb->expects( $this->exactly( 2 ) )
1390			->method( 'makeList' )
1391			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
1392		$mockDb->expects( $this->once() )
1393			->method( 'select' )
1394			->with(
1395				[ 'watchlist', 'watchlist_expiry' ],
1396				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
1397				[
1398					'wl_user' => 1,
1399					$makeListSql,
1400					'we_expiry IS NULL OR we_expiry > 20200101000000'
1401				]
1402			)
1403			->willReturn( [
1404				(object)[
1405					'wl_namespace' => 0,
1406					'wl_title' => 'SomeDbKey',
1407					'wl_notificationtimestamp' => '20151212010101',
1408					'we_expiry' => '20300101000000'
1409				]
1410			] );
1411
1412		$mockCache = $this->getMockCache();
1413		$mockCache->expects( $this->once() )
1414			->method( 'set' )
1415			->with(
1416				'0:SomeDbKey:1'
1417			);
1418
1419		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1420
1421		$watchedItem = $store->loadWatchedItem(
1422			new UserIdentityValue( 1, 'MockUser' ),
1423			$testPageFactory( 100, 0, 'SomeDbKey' )
1424		);
1425		$this->assertInstanceOf( WatchedItem::class, $watchedItem );
1426		$this->assertSame( 1, $watchedItem->getUserIdentity()->getId() );
1427		$this->assertEquals( 'SomeDbKey', $watchedItem->getTarget()->getDBkey() );
1428		$this->assertSame( '20300101000000', $watchedItem->getExpiry() );
1429		$this->assertSame( 0, $watchedItem->getTarget()->getNamespace() );
1430	}
1431
1432	/**
1433	 * @dataProvider provideTestPageFactory
1434	 */
1435	public function testLoadWatchedItem_noItem( $testPageFactory ) {
1436		$mockDb = $this->getMockDb();
1437		$mockDb->expects( $this->once() )
1438			->method( 'addQuotes' )
1439			->willReturn( '20200101000000' );
1440		$mockDb->expects( $this->once() )
1441			->method( 'select' )
1442			->willReturn( [] );
1443
1444		$mockCache = $this->getMockCache();
1445		$mockCache->expects( $this->never() )->method( 'get' );
1446		$mockCache->expects( $this->never() )->method( 'delete' );
1447
1448		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1449
1450		$this->assertFalse(
1451			$store->loadWatchedItem(
1452				new UserIdentityValue( 1, 'MockUser' ),
1453				$testPageFactory( 100, 0, 'SomeDbKey' )
1454			)
1455		);
1456	}
1457
1458	/**
1459	 * @dataProvider provideTestPageFactory
1460	 */
1461	public function testLoadWatchedItem_anonymousUser( $testPageFactory ) {
1462		$mockDb = $this->getMockDb();
1463		$mockDb->expects( $this->never() )
1464			->method( 'select' );
1465
1466		$mockCache = $this->getMockCache();
1467		$mockCache->expects( $this->never() )->method( 'get' );
1468		$mockCache->expects( $this->never() )->method( 'delete' );
1469
1470		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1471
1472		$this->assertFalse(
1473			$store->loadWatchedItem(
1474				new UserIdentityValue( 0, 'AnonUser' ),
1475				$testPageFactory( 100, 0, 'SomeDbKey' )
1476			)
1477		);
1478	}
1479
1480	/**
1481	 * @dataProvider provideTestPageFactory
1482	 */
1483	public function testRemoveWatch_existingItem( $testPageFactory ) {
1484		$mockDb = $this->getMockDb();
1485		$mockDb->expects( $this->once() )
1486			->method( 'selectFieldValues' )
1487			->willReturn( [ 1, 2 ] );
1488		$mockDb->expects( $this->exactly( 2 ) )
1489			->method( 'delete' )
1490			->withConsecutive(
1491				[
1492					'watchlist',
1493					[ 'wl_id' => [ 1, 2 ] ]
1494				],
1495				[
1496					'watchlist_expiry',
1497					[ 'we_item' => [ 1, 2 ] ]
1498				]
1499			);
1500		$mockDb->expects( $this->exactly( 2 ) )
1501			->method( 'affectedRows' )
1502			->willReturn( 2 );
1503
1504		$mockCache = $this->getMockCache();
1505		$mockCache->expects( $this->never() )->method( 'get' );
1506		$mockCache->expects( $this->once() )
1507			->method( 'delete' )
1508			->withConsecutive(
1509				[ '0:SomeDbKey:1' ],
1510				[ '1:SomeDbKey:1' ]
1511			);
1512
1513		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1514
1515		$this->assertTrue(
1516			$store->removeWatch(
1517				new UserIdentityValue( 1, 'MockUser' ),
1518				$testPageFactory( 100, 0, 'SomeDbKey' )
1519			)
1520		);
1521	}
1522
1523	/**
1524	 * @dataProvider provideTestPageFactory
1525	 */
1526	public function testRemoveWatch_noItem( $testPageFactory ) {
1527		$mockDb = $this->getMockDb();
1528		$mockDb->expects( $this->once() )
1529			->method( 'selectFieldValues' )
1530			->willReturn( [] );
1531		$mockDb->expects( $this->never() )
1532			->method( 'delete' );
1533		$mockDb->expects( $this->never() )
1534			->method( 'affectedRows' );
1535
1536		$mockCache = $this->getMockCache();
1537		$mockCache->expects( $this->never() )->method( 'get' );
1538		$mockCache->expects( $this->once() )
1539			->method( 'delete' )
1540			->withConsecutive(
1541				[ '0:SomeDbKey:1' ],
1542				[ '1:SomeDbKey:1' ]
1543			);
1544
1545		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1546
1547		$this->assertFalse(
1548			$store->removeWatch(
1549				new UserIdentityValue( 1, 'MockUser' ),
1550				$testPageFactory( 100, 0, 'SomeDbKey' )
1551			)
1552		);
1553	}
1554
1555	/**
1556	 * @dataProvider provideTestPageFactory
1557	 */
1558	public function testRemoveWatch_anonymousUser( $testPageFactory ) {
1559		$mockDb = $this->getMockDb();
1560		$mockDb->expects( $this->never() )
1561			->method( 'delete' );
1562
1563		$mockCache = $this->getMockCache();
1564		$mockCache->expects( $this->never() )->method( 'get' );
1565		$mockCache->expects( $this->never() )
1566			->method( 'delete' );
1567
1568		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1569
1570		$this->assertFalse(
1571			$store->removeWatch(
1572				new UserIdentityValue( 0, 'AnonUser' ),
1573				$testPageFactory( 100, 0, 'SomeDbKey' )
1574			)
1575		);
1576	}
1577
1578	/**
1579	 * @dataProvider provideTestPageFactory
1580	 */
1581	public function testGetWatchedItem_existingItem( $testPageFactory ) {
1582		$mockDb = $this->getMockDb();
1583		$mockDb->expects( $this->once() )
1584			->method( 'addQuotes' )
1585			->willReturn( '20200101000000' );
1586		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
1587		$mockDb->expects( $this->exactly( 2 ) )
1588			->method( 'makeList' )
1589			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
1590		$mockDb->expects( $this->once() )
1591			->method( 'select' )
1592			->with(
1593				[ 'watchlist', 'watchlist_expiry' ],
1594				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
1595				[
1596					'wl_user' => 1,
1597					$makeListSql,
1598					'we_expiry IS NULL OR we_expiry > 20200101000000'
1599				]
1600			)
1601			->willReturn( [
1602				(object)[
1603					'wl_namespace' => 0,
1604					'wl_title' => 'SomeDbKey',
1605					'wl_notificationtimestamp' => '20151212010101',
1606					'we_expiry' => '20300101000000'
1607				]
1608			] );
1609
1610		$mockCache = $this->getMockCache();
1611		$mockCache->expects( $this->never() )->method( 'delete' );
1612		$mockCache->expects( $this->once() )
1613			->method( 'get' )
1614			->with(
1615				'0:SomeDbKey:1'
1616			)
1617			->willReturn( null );
1618		$mockCache->expects( $this->once() )
1619			->method( 'set' )
1620			->with(
1621				'0:SomeDbKey:1'
1622			);
1623
1624		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1625
1626		$watchedItem = $store->getWatchedItem(
1627			new UserIdentityValue( 1, 'MockUser' ),
1628			$testPageFactory( 100, 0, 'SomeDbKey' )
1629		);
1630		$this->assertInstanceOf( WatchedItem::class, $watchedItem );
1631		$this->assertSame( 1, $watchedItem->getUserIdentity()->getId() );
1632		$this->assertEquals( 'SomeDbKey', $watchedItem->getTarget()->getDBkey() );
1633		$this->assertSame( '20300101000000', $watchedItem->getExpiry() );
1634		$this->assertSame( 0, $watchedItem->getTarget()->getNamespace() );
1635	}
1636
1637	/**
1638	 * @dataProvider provideTestPageFactory
1639	 */
1640	public function testGetWatchedItem_cachedItem( $testPageFactory ) {
1641		$mockDb = $this->getMockDb();
1642		$mockDb->expects( $this->never() )
1643			->method( 'selectRow' );
1644
1645		$mockUser = new UserIdentityValue( 1, 'MockUser' );
1646		$linkTarget = $testPageFactory( 100, 0, 'SomeDbKey' );
1647		$cachedItem = new WatchedItem( $mockUser, $linkTarget, '20151212010101' );
1648
1649		$mockCache = $this->getMockCache();
1650		$mockCache->expects( $this->never() )->method( 'delete' );
1651		$mockCache->expects( $this->never() )->method( 'set' );
1652		$mockCache->expects( $this->once() )
1653			->method( 'get' )
1654			->with(
1655				'0:SomeDbKey:1'
1656			)
1657			->willReturn( $cachedItem );
1658
1659		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1660
1661		$this->assertEquals(
1662			$cachedItem,
1663			$store->getWatchedItem(
1664				$mockUser,
1665				$linkTarget
1666			)
1667		);
1668	}
1669
1670	/**
1671	 * @dataProvider provideTestPageFactory
1672	 */
1673	public function testGetWatchedItem_noItem( $testPageFactory ) {
1674		$mockDb = $this->getMockDb();
1675		$mockDb->expects( $this->once() )
1676			->method( 'addQuotes' )
1677			->willReturn( '20200101000000' );
1678		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
1679		$mockDb->expects( $this->exactly( 2 ) )
1680			->method( 'makeList' )
1681			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
1682		$mockDb->expects( $this->once() )
1683			->method( 'select' )
1684			->with(
1685				[ 'watchlist', 'watchlist_expiry' ],
1686				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
1687				[
1688					'wl_user' => 1,
1689					$makeListSql,
1690					'we_expiry IS NULL OR we_expiry > 20200101000000'
1691				]
1692			)
1693			->willReturn( [] );
1694
1695		$mockCache = $this->getMockCache();
1696		$mockCache->expects( $this->never() )->method( 'set' );
1697		$mockCache->expects( $this->never() )->method( 'delete' );
1698		$mockCache->expects( $this->once() )
1699			->method( 'get' )
1700			->with( '0:SomeDbKey:1' )
1701			->willReturn( false );
1702
1703		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1704
1705		$this->assertFalse(
1706			$store->getWatchedItem(
1707				new UserIdentityValue( 1, 'MockUser' ),
1708				$testPageFactory( 100, 0, 'SomeDbKey' )
1709			)
1710		);
1711	}
1712
1713	/**
1714	 * @dataProvider provideTestPageFactory
1715	 */
1716	public function testGetWatchedItem_anonymousUser( $testPageFactory ) {
1717		$mockDb = $this->getMockDb();
1718		$mockDb->expects( $this->never() )
1719			->method( 'selectRow' );
1720
1721		$mockCache = $this->getMockCache();
1722		$mockCache->expects( $this->never() )->method( 'set' );
1723		$mockCache->expects( $this->never() )->method( 'get' );
1724		$mockCache->expects( $this->never() )->method( 'delete' );
1725
1726		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1727
1728		$this->assertFalse(
1729			$store->getWatchedItem(
1730				new UserIdentityValue( 0, 'AnonUser' ),
1731				$testPageFactory( 100, 0, 'SomeDbKey' )
1732			)
1733		);
1734	}
1735
1736	public function testGetWatchedItemsForUser() {
1737		$mockDb = $this->getMockDb();
1738		$mockDb->expects( $this->once() )
1739			->method( 'addQuotes' )
1740			->willReturn( '20200101000000' );
1741		$mockDb->expects( $this->once() )
1742			->method( 'select' )
1743			->with(
1744				[ 'watchlist', 'watchlist_expiry' ],
1745				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
1746				[ 'wl_user' => 1, 'we_expiry IS NULL OR we_expiry > 20200101000000' ]
1747			)
1748			->willReturn( [
1749				(object)[
1750					'wl_namespace' => 0,
1751					'wl_title' => 'Foo1',
1752					'wl_notificationtimestamp' => '20151212010101',
1753					'we_expiry' => '20300101000000'
1754				],
1755				(object)[
1756					'wl_namespace' => 1,
1757					'wl_title' => 'Foo2',
1758					'wl_notificationtimestamp' => null,
1759				],
1760			] );
1761
1762		$mockCache = $this->getMockCache();
1763		$mockCache->expects( $this->never() )->method( 'delete' );
1764		$mockCache->expects( $this->never() )->method( 'get' );
1765		$mockCache->expects( $this->never() )->method( 'set' );
1766
1767		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1768		$user = new UserIdentityValue( 1, 'MockUser' );
1769
1770		$watchedItems = $store->getWatchedItemsForUser( $user );
1771
1772		$this->assertIsArray( $watchedItems );
1773		$this->assertCount( 2, $watchedItems );
1774		foreach ( $watchedItems as $watchedItem ) {
1775			$this->assertInstanceOf( WatchedItem::class, $watchedItem );
1776		}
1777		$this->assertEquals(
1778			new WatchedItem(
1779				$user,
1780				new TitleValue( 0, 'Foo1' ),
1781				'20151212010101',
1782				'20300101000000'
1783			),
1784			$watchedItems[0]
1785		);
1786		$this->assertEquals(
1787			new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
1788			$watchedItems[1]
1789		);
1790	}
1791
1792	public function provideDbTypes() {
1793		return [
1794			[ false, DB_REPLICA ],
1795			[ true, DB_PRIMARY ],
1796		];
1797	}
1798
1799	/**
1800	 * @dataProvider provideDbTypes
1801	 */
1802	public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) {
1803		$mockDb = $this->getMockDb();
1804		$mockCache = $this->getMockCache();
1805		$mockLoadBalancer = $this->getMockLBFactory( $mockDb, $dbType );
1806		$user = new UserIdentityValue( 1, 'MockUser' );
1807
1808		$mockDb->expects( $this->once() )
1809			->method( 'addQuotes' )
1810			->willReturn( '20200101000000' );
1811		$mockDb->expects( $this->once() )
1812			->method( 'select' )
1813			->with(
1814				[ 'watchlist', 'watchlist_expiry' ],
1815				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
1816				[ 'wl_user' => 1, 'we_expiry IS NULL OR we_expiry > 20200101000000' ],
1817				$this->isType( 'string' ),
1818				[ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
1819			)
1820			->willReturn( [] );
1821
1822		$store = $this->newWatchedItemStore(
1823			[ 'lbFactory' => $mockLoadBalancer, 'cache' => $mockCache ] );
1824
1825		$watchedItems = $store->getWatchedItemsForUser(
1826			$user,
1827			[ 'forWrite' => $forWrite, 'sort' => WatchedItemStore::SORT_ASC ]
1828		);
1829		$this->assertEquals( [], $watchedItems );
1830	}
1831
1832	public function testGetWatchedItemsForUser_sortByExpiry() {
1833		$mockDb = $this->getMockDb();
1834		$mockDb->expects( $this->once() )
1835			->method( 'addQuotes' )
1836			->willReturn( '20200101000000' );
1837		$mockDb->expects( $this->once() )
1838			->method( 'select' )
1839			->with(
1840				[ 'watchlist', 'watchlist_expiry' ],
1841				[
1842					'wl_namespace',
1843					'wl_title',
1844					'wl_notificationtimestamp',
1845					'we_expiry',
1846					'wl_has_expiry' => null
1847				],
1848				[ 'wl_user' => 1, 'we_expiry IS NULL OR we_expiry > 20200101000000' ]
1849			)
1850			->willReturn( [
1851				(object)[
1852					'wl_namespace' => 0,
1853					'wl_title' => 'Foo1',
1854					'wl_notificationtimestamp' => '20151212010101',
1855					'we_expiry' => '20300101000000'
1856				],
1857				(object)[
1858					'wl_namespace' => 0,
1859					'wl_title' => 'Foo2',
1860					'wl_notificationtimestamp' => '20151212010101',
1861					'we_expiry' => '20300701000000'
1862				],
1863				(object)[
1864					'wl_namespace' => 1,
1865					'wl_title' => 'Foo3',
1866					'wl_notificationtimestamp' => null,
1867				],
1868			] );
1869
1870		$mockCache = $this->getMockCache();
1871		$mockCache->expects( $this->never() )->method( 'delete' );
1872		$mockCache->expects( $this->never() )->method( 'get' );
1873		$mockCache->expects( $this->never() )->method( 'set' );
1874
1875		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1876		$user = new UserIdentityValue( 1, 'MockUser' );
1877
1878		$watchedItems = $store->getWatchedItemsForUser(
1879			$user,
1880			[ 'sortByExpiry' => true, 'sort' => WatchedItemStore::SORT_ASC ]
1881		);
1882
1883		$this->assertIsArray( $watchedItems );
1884		$this->assertCount( 3, $watchedItems );
1885		foreach ( $watchedItems as $watchedItem ) {
1886			$this->assertInstanceOf( WatchedItem::class, $watchedItem );
1887		}
1888		$this->assertEquals(
1889			new WatchedItem(
1890				$user,
1891				new TitleValue( 0, 'Foo1' ),
1892				'20151212010101',
1893				'20300101000000'
1894			),
1895			$watchedItems[0]
1896		);
1897		$this->assertEquals(
1898			new WatchedItem( $user, new TitleValue( 1, 'Foo3' ), null ),
1899			$watchedItems[2]
1900		);
1901	}
1902
1903	public function testGetWatchedItemsForUser_badSortOptionThrowsException() {
1904		$store = $this->newWatchedItemStore();
1905
1906		$this->expectException( InvalidArgumentException::class );
1907		$store->getWatchedItemsForUser(
1908			new UserIdentityValue( 1, 'MockUser' ),
1909			[ 'sort' => 'foo' ]
1910		);
1911	}
1912
1913	/**
1914	 * @dataProvider provideTestPageFactory
1915	 */
1916	public function testIsWatchedItem_existingItem( $testPageFactory ) {
1917		$mockDb = $this->getMockDb();
1918		$mockDb->expects( $this->once() )
1919			->method( 'addQuotes' )
1920			->willReturn( '20200101000000' );
1921		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
1922		$mockDb->expects( $this->exactly( 2 ) )
1923			->method( 'makeList' )
1924			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
1925		$mockDb->expects( $this->once() )
1926			->method( 'select' )
1927			->with(
1928				[ 'watchlist', 'watchlist_expiry' ],
1929				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
1930				[
1931					'wl_user' => 1,
1932					$makeListSql,
1933					'we_expiry IS NULL OR we_expiry > 20200101000000'
1934				]
1935			)
1936			->willReturn( [
1937				(object)[
1938					'wl_namespace' => 0,
1939					'wl_title' => 'SomeDbKey',
1940					'wl_notificationtimestamp' => '20151212010101',
1941				]
1942			] );
1943
1944		$mockCache = $this->getMockCache();
1945		$mockCache->expects( $this->never() )->method( 'delete' );
1946		$mockCache->expects( $this->once() )
1947			->method( 'get' )
1948			->with( '0:SomeDbKey:1' )
1949			->willReturn( false );
1950		$mockCache->expects( $this->once() )
1951			->method( 'set' )
1952			->with(
1953				'0:SomeDbKey:1'
1954			);
1955
1956		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
1957
1958		$this->assertTrue(
1959			$store->isWatched(
1960				new UserIdentityValue( 1, 'MockUser' ),
1961				$testPageFactory( 100, 0, 'SomeDbKey' )
1962			)
1963		);
1964	}
1965
1966	/**
1967	 * @dataProvider provideTestPageFactory
1968	 */
1969	public function testIsWatchedItem_noItem( $testPageFactory ) {
1970		$mockDb = $this->getMockDb();
1971		$mockDb->expects( $this->once() )
1972			->method( 'addQuotes' )
1973			->willReturn( '20200101000000' );
1974		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
1975		$mockDb->expects( $this->exactly( 2 ) )
1976			->method( 'makeList' )
1977			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
1978		$mockDb->expects( $this->once() )
1979			->method( 'select' )
1980			->with(
1981				[ 'watchlist', 'watchlist_expiry' ],
1982				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
1983				[
1984					'wl_user' => 1,
1985					$makeListSql,
1986					'we_expiry IS NULL OR we_expiry > 20200101000000'
1987				]
1988			)
1989			->willReturn( [] );
1990
1991		$mockCache = $this->getMockCache();
1992		$mockCache->expects( $this->never() )->method( 'set' );
1993		$mockCache->expects( $this->never() )->method( 'delete' );
1994		$mockCache->expects( $this->once() )
1995			->method( 'get' )
1996			->with( '0:SomeDbKey:1' )
1997			->willReturn( false );
1998
1999		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
2000
2001		$this->assertFalse(
2002			$store->isWatched(
2003				new UserIdentityValue( 1, 'MockUser' ),
2004				$testPageFactory( 100, 0, 'SomeDbKey' )
2005			)
2006		);
2007	}
2008
2009	/**
2010	 * @dataProvider provideTestPageFactory
2011	 */
2012	public function testIsWatchedItem_anonymousUser( $testPageFactory ) {
2013		$mockDb = $this->getMockDb();
2014		$mockDb->expects( $this->never() )
2015			->method( 'selectRow' );
2016
2017		$mockCache = $this->getMockCache();
2018		$mockCache->expects( $this->never() )->method( 'set' );
2019		$mockCache->expects( $this->never() )->method( 'get' );
2020		$mockCache->expects( $this->never() )->method( 'delete' );
2021
2022		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
2023
2024		$this->assertFalse(
2025			$store->isWatched(
2026				new UserIdentityValue( 0, 'AnonUser' ),
2027				$testPageFactory( 100, 0, 'SomeDbKey' )
2028			)
2029		);
2030	}
2031
2032	/**
2033	 * @dataProvider provideTestPageFactory
2034	 */
2035	public function testGetNotificationTimestampsBatch( $testPageFactory ) {
2036		$targets = [
2037			$testPageFactory( 100, 0, 'SomeDbKey' ),
2038			$testPageFactory( 101, 1, 'AnotherDbKey' ),
2039		];
2040
2041		$mockDb = $this->getMockDb();
2042		$dbResult = [
2043			(object)[
2044				'wl_namespace' => '0',
2045				'wl_title' => 'SomeDbKey',
2046				'wl_notificationtimestamp' => '20151212010101',
2047			],
2048			(object)[
2049				'wl_namespace' => '1',
2050				'wl_title' => 'AnotherDbKey',
2051				'wl_notificationtimestamp' => null,
2052			],
2053		];
2054
2055		$mockDb->expects( $this->once() )
2056			->method( 'makeWhereFrom2d' )
2057			->with(
2058				[ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
2059				$this->isType( 'string' ),
2060				$this->isType( 'string' )
2061			)
2062			->willReturn( 'makeWhereFrom2d return value' );
2063		$mockDb->expects( $this->once() )
2064			->method( 'select' )
2065			->with(
2066				'watchlist',
2067				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
2068				[
2069					'makeWhereFrom2d return value',
2070					'wl_user' => 1
2071				],
2072				$this->isType( 'string' )
2073			)
2074			->willReturn( $dbResult );
2075
2076		$mockCache = $this->getMockCache();
2077		$mockCache->expects( $this->exactly( 2 ) )
2078			->method( 'get' )
2079			->withConsecutive(
2080				[ '0:SomeDbKey:1' ],
2081				[ '1:AnotherDbKey:1' ]
2082			)
2083			->willReturn( null );
2084		$mockCache->expects( $this->never() )->method( 'set' );
2085		$mockCache->expects( $this->never() )->method( 'delete' );
2086
2087		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
2088
2089		$this->assertEquals(
2090			[
2091				0 => [ 'SomeDbKey' => '20151212010101', ],
2092				1 => [ 'AnotherDbKey' => null, ],
2093			],
2094			$store->getNotificationTimestampsBatch(
2095				new UserIdentityValue( 1, 'MockUser' ), $targets )
2096		);
2097	}
2098
2099	/**
2100	 * @dataProvider provideTestPageFactory
2101	 */
2102	public function testGetNotificationTimestampsBatch_notWatchedTarget( $testPageFactory ) {
2103		$targets = [
2104			$testPageFactory( 100, 0, 'OtherDbKey' ),
2105		];
2106
2107		$mockDb = $this->getMockDb();
2108
2109		$mockDb->expects( $this->once() )
2110			->method( 'makeWhereFrom2d' )
2111			->with(
2112				[ [ 'OtherDbKey' => 1 ] ],
2113				$this->isType( 'string' ),
2114				$this->isType( 'string' )
2115			)
2116			->willReturn( 'makeWhereFrom2d return value' );
2117		$mockDb->expects( $this->once() )
2118			->method( 'select' )
2119			->with(
2120				'watchlist',
2121				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
2122				[
2123					'makeWhereFrom2d return value',
2124					'wl_user' => 1
2125				],
2126				$this->isType( 'string' )
2127			)
2128			->willReturn( (object)[] );
2129
2130		$mockCache = $this->getMockCache();
2131		$mockCache->expects( $this->once() )
2132			->method( 'get' )
2133			->with( '0:OtherDbKey:1' )
2134			->willReturn( null );
2135		$mockCache->expects( $this->never() )->method( 'set' );
2136		$mockCache->expects( $this->never() )->method( 'delete' );
2137
2138		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
2139
2140		$this->assertEquals(
2141			[
2142				0 => [ 'OtherDbKey' => false, ],
2143			],
2144			$store->getNotificationTimestampsBatch(
2145				new UserIdentityValue( 1, 'MockUser' ), $targets )
2146		);
2147	}
2148
2149	/**
2150	 * @dataProvider provideTestPageFactory
2151	 */
2152	public function testGetNotificationTimestampsBatch_cachedItem( $testPageFactory ) {
2153		$targets = [
2154			$testPageFactory( 100, 0, 'SomeDbKey' ),
2155			$testPageFactory( 101, 1, 'AnotherDbKey' ),
2156		];
2157
2158		$user = new UserIdentityValue( 1, 'MockUser' );
2159		$cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' );
2160
2161		$mockDb = $this->getMockDb();
2162
2163		$mockDb->expects( $this->once() )
2164			->method( 'makeWhereFrom2d' )
2165			->with(
2166				[ 1 => [ 'AnotherDbKey' => 1 ] ],
2167				$this->isType( 'string' ),
2168				$this->isType( 'string' )
2169			)
2170			->willReturn( 'makeWhereFrom2d return value' );
2171		$mockDb->expects( $this->once() )
2172			->method( 'select' )
2173			->with(
2174				'watchlist',
2175				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
2176				[
2177					'makeWhereFrom2d return value',
2178					'wl_user' => 1
2179				],
2180				$this->isType( 'string' )
2181			)
2182			->willReturn( [
2183				(object)[ 'wl_namespace' => '1', 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ]
2184			] );
2185
2186		$mockCache = $this->getMockCache();
2187		$mockCache->expects( $this->at( 1 ) )
2188			->method( 'get' )
2189			->with( '0:SomeDbKey:1' )
2190			->willReturn( $cachedItem );
2191		$mockCache->expects( $this->at( 3 ) )
2192			->method( 'get' )
2193			->with( '1:AnotherDbKey:1' )
2194			->willReturn( null );
2195		$mockCache->expects( $this->never() )->method( 'set' );
2196		$mockCache->expects( $this->never() )->method( 'delete' );
2197
2198		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
2199
2200		$this->assertEquals(
2201			[
2202				0 => [ 'SomeDbKey' => '20151212010101', ],
2203				1 => [ 'AnotherDbKey' => null, ],
2204			],
2205			$store->getNotificationTimestampsBatch( $user, $targets )
2206		);
2207	}
2208
2209	/**
2210	 * @dataProvider provideTestPageFactory
2211	 */
2212	public function testGetNotificationTimestampsBatch_allItemsCached( $testPageFactory ) {
2213		$targets = [
2214			$testPageFactory( 100, 0, 'SomeDbKey' ),
2215			$testPageFactory( 101, 1, 'AnotherDbKey' ),
2216		];
2217
2218		$user = new UserIdentityValue( 1, 'MockUser' );
2219		$cachedItems = [
2220			new WatchedItem( $user, $targets[0], '20151212010101' ),
2221			new WatchedItem( $user, $targets[1], null ),
2222		];
2223		$mockDb = $this->createNoOpMock( IDatabase::class );
2224
2225		$mockCache = $this->getMockCache();
2226		$mockCache->expects( $this->at( 1 ) )
2227			->method( 'get' )
2228			->with( '0:SomeDbKey:1' )
2229			->willReturn( $cachedItems[0] );
2230		$mockCache->expects( $this->at( 3 ) )
2231			->method( 'get' )
2232			->with( '1:AnotherDbKey:1' )
2233			->willReturn( $cachedItems[1] );
2234		$mockCache->expects( $this->never() )->method( 'set' );
2235		$mockCache->expects( $this->never() )->method( 'delete' );
2236
2237		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
2238
2239		$this->assertEquals(
2240			[
2241				0 => [ 'SomeDbKey' => '20151212010101', ],
2242				1 => [ 'AnotherDbKey' => null, ],
2243			],
2244			$store->getNotificationTimestampsBatch( $user, $targets )
2245		);
2246	}
2247
2248	/**
2249	 * @dataProvider provideTestPageFactory
2250	 */
2251	public function testGetNotificationTimestampsBatch_anonymousUser( $testPageFactory ) {
2252		$targets = [
2253			$testPageFactory( 100, 0, 'SomeDbKey' ),
2254			$testPageFactory( 101, 1, 'AnotherDbKey' ),
2255		];
2256
2257		$mockDb = $this->createNoOpMock( IDatabase::class );
2258
2259		$mockCache = $this->createNoOpMock( HashBagOStuff::class );
2260
2261		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
2262
2263		$this->assertEquals(
2264			[
2265				0 => [ 'SomeDbKey' => false, ],
2266				1 => [ 'AnotherDbKey' => false, ],
2267			],
2268			$store->getNotificationTimestampsBatch(
2269				new UserIdentityValue( 0, 'AnonUser' ), $targets )
2270		);
2271	}
2272
2273	/**
2274	 * @dataProvider provideTestPageFactory
2275	 */
2276	public function testResetNotificationTimestamp_anonymousUser( $testPageFactory ) {
2277		$mockDb = $this->getMockDb();
2278		$mockDb->expects( $this->never() )
2279			->method( 'selectRow' );
2280
2281		$mockCache = $this->getMockCache();
2282		$mockCache->expects( $this->never() )->method( 'get' );
2283		$mockCache->expects( $this->never() )->method( 'set' );
2284		$mockCache->expects( $this->never() )->method( 'delete' );
2285
2286		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
2287
2288		$this->assertFalse(
2289			$store->resetNotificationTimestamp(
2290				new UserIdentityValue( 0, 'AnonUser' ),
2291				$testPageFactory( 100, 0, 'SomeDbKey' )
2292			)
2293		);
2294	}
2295
2296	/**
2297	 * @dataProvider provideTestPageFactory
2298	 */
2299	public function testResetNotificationTimestamp_noItem( $testPageFactory ) {
2300		$mockDb = $this->getMockDb();
2301		$mockDb->expects( $this->once() )
2302			->method( 'addQuotes' )
2303			->willReturn( '20200101000000' );
2304		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
2305		$mockDb->expects( $this->exactly( 2 ) )
2306			->method( 'makeList' )
2307			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
2308		$mockDb->expects( $this->once() )
2309			->method( 'select' )
2310			->with(
2311				[ 'watchlist', 'watchlist_expiry' ],
2312				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
2313				[
2314					'wl_user' => 1,
2315					$makeListSql,
2316					'we_expiry IS NULL OR we_expiry > 20200101000000'
2317				]
2318			)
2319			->willReturn( [] );
2320
2321		$mockCache = $this->getMockCache();
2322		$mockCache->expects( $this->never() )->method( 'get' );
2323		$mockCache->expects( $this->never() )->method( 'set' );
2324		$mockCache->expects( $this->never() )->method( 'delete' );
2325
2326		$user = new UserIdentityValue( 1, 'MockUser' );
2327
2328		$title = $testPageFactory( 100, 0, 'SomeDbKey' );
2329		$titleFactory = $this->getTitleFactory( $title );
2330
2331		$store = $this->newWatchedItemStore( [
2332			'db' => $mockDb,
2333			'cache' => $mockCache,
2334			'titleFactory' => $titleFactory,
2335		] );
2336
2337		$this->assertFalse(
2338			$store->resetNotificationTimestamp(
2339				$user,
2340				$title
2341			)
2342		);
2343	}
2344
2345	/**
2346	 * @dataProvider provideTestPageFactory
2347	 */
2348	public function testResetNotificationTimestamp_item( $testPageFactory ) {
2349		$user = new UserIdentityValue( 1, 'MockUser' );
2350		$title = $testPageFactory( 100, 0, 'SomeDbKey' );
2351
2352		$mockDb = $this->getMockDb();
2353		$mockDb->expects( $this->once() )
2354			->method( 'addQuotes' )
2355			->willReturn( '20200101000000' );
2356		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
2357		$mockDb->expects( $this->exactly( 2 ) )
2358			->method( 'makeList' )
2359			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
2360		$mockDb->expects( $this->once() )
2361			->method( 'select' )
2362			->with(
2363				[ 'watchlist', 'watchlist_expiry' ],
2364				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
2365				[
2366					'wl_user' => 1,
2367					$makeListSql,
2368					'we_expiry IS NULL OR we_expiry > 20200101000000'
2369				]
2370			)
2371			->willReturn( [
2372				(object)[
2373					'wl_namespace' => 0,
2374					'wl_title' => 'SomeDbKey',
2375					'wl_notificationtimestamp' => '20151212010101',
2376				]
2377			] );
2378
2379		$mockCache = $this->getMockCache();
2380		$mockCache->expects( $this->never() )->method( 'get' );
2381		$mockCache->expects( $this->once() )
2382			->method( 'set' )
2383			->with(
2384				'0:SomeDbKey:1',
2385				$this->isInstanceOf( WatchedItem::class )
2386			);
2387		$mockCache->expects( $this->once() )
2388			->method( 'delete' )
2389			->with( '0:SomeDbKey:1' );
2390
2391		$mockQueueGroup = $this->getMockJobQueueGroup( false );
2392		$mockQueueGroup->expects( $this->once() )
2393			->method( 'lazyPush' )
2394			->willReturnCallback( static function ( ActivityUpdateJob $job ) {
2395				// don't run
2396			} );
2397
2398		// We don't care if these methods actually do anything here
2399		$mockRevisionLookup = $this->getMockRevisionLookup( [
2400			'getRevisionByTitle' => static function () {
2401				return null;
2402			},
2403			'getTimestampFromId' => static function () {
2404				return '00000000000000';
2405			},
2406		] );
2407
2408		$titleFactory = $this->getTitleFactory( $title );
2409
2410		$store = $this->newWatchedItemStore( [
2411			'db' => $mockDb,
2412			'queueGroup' => $mockQueueGroup,
2413			'cache' => $mockCache,
2414			'revisionLookup' => $mockRevisionLookup,
2415			'titleFactory' => $titleFactory,
2416		] );
2417
2418		$this->assertTrue(
2419			$store->resetNotificationTimestamp(
2420				$user,
2421				$title
2422			)
2423		);
2424	}
2425
2426	/**
2427	 * @dataProvider provideTestPageFactory
2428	 */
2429	public function testResetNotificationTimestamp_noItemForced( $testPageFactory ) {
2430		$user = new UserIdentityValue( 1, 'MockUser' );
2431		$title = $testPageFactory( 100, 0, 'SomeDbKey' );
2432
2433		$mockDb = $this->getMockDb();
2434		$mockDb->expects( $this->never() )
2435			->method( 'selectRow' );
2436
2437		$mockCache = $this->getMockCache();
2438		$mockCache->expects( $this->never() )->method( 'get' );
2439		$mockCache->expects( $this->never() )->method( 'set' );
2440		$mockCache->expects( $this->once() )
2441			->method( 'delete' )
2442			->with( '0:SomeDbKey:1' );
2443
2444		$mockQueueGroup = $this->getMockJobQueueGroup( false );
2445
2446		// We don't care if these methods actually do anything here
2447		$mockRevisionLookup = $this->getMockRevisionLookup( [
2448			'getRevisionByTitle' => static function () {
2449				return null;
2450			},
2451			'getTimestampFromId' => static function () {
2452				return '00000000000000';
2453			},
2454		] );
2455
2456		$titleFactory = $this->getTitleFactory( $title );
2457
2458		$store = $this->newWatchedItemStore( [
2459			'db' => $mockDb,
2460			'queueGroup' => $mockQueueGroup,
2461			'cache' => $mockCache,
2462			'revisionLookup' => $mockRevisionLookup,
2463			'titleFactory' => $titleFactory,
2464		] );
2465
2466		$mockQueueGroup->method( 'lazyPush' )
2467			->willReturnCallback( static function ( ActivityUpdateJob $job ) {
2468				// don't run
2469			} );
2470
2471		$this->assertTrue(
2472			$store->resetNotificationTimestamp(
2473				$user,
2474				$title,
2475				'force'
2476			)
2477		);
2478	}
2479
2480	/**
2481	 * @param ActivityUpdateJob $job
2482	 * @param LinkTarget|PageIdentity $expectedTitle
2483	 * @param string $expectedUserId
2484	 * @param callable $notificationTimestampCondition
2485	 */
2486	private function verifyCallbackJob(
2487		ActivityUpdateJob $job,
2488		$expectedTitle,
2489		$expectedUserId,
2490		callable $notificationTimestampCondition
2491	) {
2492		$this->assertEquals( $expectedTitle->getDBkey(), $job->getTitle()->getDBkey() );
2493		$this->assertEquals( $expectedTitle->getNamespace(), $job->getTitle()->getNamespace() );
2494
2495		$jobParams = $job->getParams();
2496		$this->assertArrayHasKey( 'type', $jobParams );
2497		$this->assertEquals( 'updateWatchlistNotification', $jobParams['type'] );
2498		$this->assertArrayHasKey( 'userid', $jobParams );
2499		$this->assertEquals( $expectedUserId, $jobParams['userid'] );
2500		$this->assertArrayHasKey( 'notifTime', $jobParams );
2501		$this->assertTrue( $notificationTimestampCondition( $jobParams['notifTime'] ) );
2502	}
2503
2504	/**
2505	 * @dataProvider provideTestPageFactory
2506	 */
2507	public function testResetNotificationTimestamp_oldidSpecifiedLatestRevisionForced( $testPageFactory ) {
2508		$user = new UserIdentityValue( 1, 'MockUser' );
2509		$oldid = 22;
2510		$title = $testPageFactory( 100, 0, 'SomeTitle' );
2511
2512		$mockDb = $this->getMockDb();
2513		$mockDb->expects( $this->never() )
2514			->method( 'selectRow' );
2515
2516		$mockCache = $this->getMockCache();
2517		$mockCache->expects( $this->never() )->method( 'get' );
2518		$mockCache->expects( $this->never() )->method( 'set' );
2519		$mockCache->expects( $this->once() )
2520			->method( 'delete' )
2521			->with( '0:SomeTitle:1' );
2522
2523		$mockQueueGroup = $this->getMockJobQueueGroup( false );
2524
2525		$mockRevisionRecord = $this->createNoOpMock( RevisionRecord::class );
2526
2527		$mockRevisionLookup = $this->getMockRevisionLookup( [
2528			'getTimestampFromId' => static function () {
2529				return '00000000000000';
2530			},
2531			'getRevisionById' => function ( $id, $flags ) use ( $oldid, $mockRevisionRecord ) {
2532				$this->assertSame( $oldid, $id );
2533				$this->assertSame( 0, $flags );
2534				return $mockRevisionRecord;
2535			},
2536			'getNextRevision' =>
2537			function ( $oldRev ) use ( $mockRevisionRecord ) {
2538				$this->assertSame( $mockRevisionRecord, $oldRev );
2539				return false;
2540			},
2541		], [
2542			'getNextRevision' => 1,
2543		] );
2544
2545		$titleFactory = $this->getTitleFactory( $title );
2546
2547		$store = $this->newWatchedItemStore( [
2548			'db' => $mockDb,
2549			'queueGroup' => $mockQueueGroup,
2550			'cache' => $mockCache,
2551			'revisionLookup' => $mockRevisionLookup,
2552			'titleFactory' => $titleFactory,
2553		] );
2554
2555		$mockQueueGroup->method( 'lazyPush' )
2556			->willReturnCallback(
2557				function ( ActivityUpdateJob $job ) use ( $title, $user ) {
2558					$this->verifyCallbackJob(
2559						$job,
2560						$title,
2561						$user->getId(),
2562						static function ( $time ) {
2563							return $time === null;
2564						}
2565					);
2566				}
2567			);
2568
2569		$this->assertTrue(
2570			$store->resetNotificationTimestamp(
2571				$user,
2572				$title,
2573				'force',
2574				$oldid
2575			)
2576		);
2577	}
2578
2579	/**
2580	 * @dataProvider provideTestPageFactory
2581	 */
2582	public function testResetNotificationTimestamp_oldidSpecifiedNotLatestRevisionForced( $testPageFactory ) {
2583		$user = new UserIdentityValue( 1, 'MockUser' );
2584		$oldid = 22;
2585		$title = $testPageFactory( 100, 0, 'SomeDbKey' );
2586
2587		$mockRevision = $this->createNoOpMock( RevisionRecord::class );
2588		$mockNextRevision = $this->createNoOpMock( RevisionRecord::class );
2589
2590		$mockDb = $this->getMockDb();
2591		$mockDb->expects( $this->once() )
2592			->method( 'addQuotes' )
2593			->willReturn( '20200101000000' );
2594		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
2595		$mockDb->expects( $this->exactly( 2 ) )
2596			->method( 'makeList' )
2597			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
2598		$mockDb->expects( $this->once() )
2599			->method( 'select' )
2600			->with(
2601				[ 'watchlist', 'watchlist_expiry' ],
2602				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
2603				[
2604					'wl_user' => 1,
2605					$makeListSql,
2606					'we_expiry IS NULL OR we_expiry > 20200101000000'
2607				]
2608			)
2609			->willReturn( [
2610				(object)[
2611					'wl_namespace' => 0,
2612					'wl_title' => 'SomeDbKey',
2613					'wl_notificationtimestamp' => '20151212010101',
2614				]
2615			] );
2616
2617		$mockCache = $this->getMockCache();
2618		$mockCache->expects( $this->never() )->method( 'get' );
2619		$mockCache->expects( $this->once() )
2620			->method( 'set' )
2621			->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
2622		$mockCache->expects( $this->once() )
2623			->method( 'delete' )
2624			->with( '0:SomeDbKey:1' );
2625
2626		$mockQueueGroup = $this->getMockJobQueueGroup( false );
2627
2628		$mockRevisionLookup = $this->getMockRevisionLookup(
2629			[
2630				'getTimestampFromId' => function ( $oldidParam ) use ( $oldid ) {
2631					$this->assertSame( $oldid, $oldidParam );
2632				},
2633				'getRevisionById' => function ( $id ) use ( $oldid, $mockRevision ) {
2634					$this->assertSame( $oldid, $id );
2635					return $mockRevision;
2636				},
2637				'getNextRevision' =>
2638				function ( RevisionRecord $rev ) use ( $mockRevision, $mockNextRevision ) {
2639					$this->assertSame( $mockRevision, $rev );
2640					return $mockNextRevision;
2641				},
2642			],
2643			[
2644				'getTimestampFromId' => 2,
2645				'getRevisionById' => 1,
2646				'getNextRevision' => 1,
2647			]
2648		);
2649
2650		$titleFactory = $this->getTitleFactory( $title );
2651
2652		$store = $this->newWatchedItemStore( [
2653			'db' => $mockDb,
2654			'queueGroup' => $mockQueueGroup,
2655			'cache' => $mockCache,
2656			'revisionLookup' => $mockRevisionLookup,
2657			'titleFactory' => $titleFactory,
2658		] );
2659
2660		$mockQueueGroup->method( 'lazyPush' )
2661			->willReturnCallback(
2662				function ( ActivityUpdateJob $job ) use ( $title, $user ) {
2663					$this->verifyCallbackJob(
2664						$job,
2665						$title,
2666						$user->getId(),
2667						static function ( $time ) {
2668							return $time !== null && $time > '20151212010101';
2669						}
2670					);
2671				}
2672			);
2673
2674		$this->assertTrue(
2675			$store->resetNotificationTimestamp(
2676				$user,
2677				$title,
2678				'force',
2679				$oldid
2680			)
2681		);
2682	}
2683
2684	/**
2685	 * @dataProvider provideTestPageFactory
2686	 */
2687	public function testResetNotificationTimestamp_notWatchedPageForced( $testPageFactory ) {
2688		$user = new UserIdentityValue( 1, 'MockUser' );
2689		$oldid = 22;
2690		$title = $testPageFactory( 100, 0, 'SomeDbKey' );
2691
2692		$mockDb = $this->getMockDb();
2693		$mockDb->expects( $this->once() )
2694			->method( 'addQuotes' )
2695			->willReturn( '20200101000000' );
2696		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
2697		$mockDb->expects( $this->exactly( 2 ) )
2698			->method( 'makeList' )
2699			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
2700		$mockDb->expects( $this->once() )
2701			->method( 'select' )
2702			->with(
2703				[ 'watchlist', 'watchlist_expiry' ],
2704				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
2705				[
2706					'wl_user' => 1,
2707					$makeListSql,
2708					'we_expiry IS NULL OR we_expiry > 20200101000000'
2709				]
2710			)
2711			->willReturn( false );
2712
2713		$mockCache = $this->getMockCache();
2714		$mockCache->expects( $this->never() )->method( 'get' );
2715		$mockCache->expects( $this->never() )->method( 'set' );
2716		$mockCache->expects( $this->once() )
2717			->method( 'delete' )
2718			->with( '0:SomeDbKey:1' );
2719
2720		$mockQueueGroup = $this->getMockJobQueueGroup( false );
2721
2722		$mockRevision = $this->createNoOpMock( RevisionRecord::class );
2723		$mockNextRevision = $this->createNoOpMock( RevisionRecord::class );
2724
2725		$mockRevisionLookup = $this->getMockRevisionLookup(
2726			[
2727				'getTimestampFromId' => function ( $oldidParam ) use ( $oldid ) {
2728					$this->assertSame( $oldid, $oldidParam );
2729				},
2730				'getRevisionById' => function ( $id ) use ( $oldid, $mockRevision ) {
2731					$this->assertSame( $oldid, $id );
2732					return $mockRevision;
2733				},
2734				'getNextRevision' =>
2735				function ( RevisionRecord $rev ) use ( $mockRevision, $mockNextRevision ) {
2736					$this->assertSame( $mockRevision, $rev );
2737					return $mockNextRevision;
2738				},
2739			],
2740			[
2741				'getTimestampFromId' => 1,
2742				'getRevisionById' => 1,
2743				'getNextRevision' => 1,
2744			]
2745		);
2746
2747		$titleFactory = $this->getTitleFactory( $title );
2748
2749		$store = $this->newWatchedItemStore( [
2750			'db' => $mockDb,
2751			'queueGroup' => $mockQueueGroup,
2752			'cache' => $mockCache,
2753			'revisionLookup' => $mockRevisionLookup,
2754			'titleFactory' => $titleFactory,
2755		] );
2756
2757		$mockQueueGroup->method( 'lazyPush' )
2758			->willReturnCallback(
2759				function ( ActivityUpdateJob $job ) use ( $title, $user ) {
2760					$this->verifyCallbackJob(
2761						$job,
2762						$title,
2763						$user->getId(),
2764						static function ( $time ) {
2765							return $time === null;
2766						}
2767					);
2768				}
2769			);
2770
2771		$this->assertTrue(
2772			$store->resetNotificationTimestamp(
2773				$user,
2774				$title,
2775				'force',
2776				$oldid
2777			)
2778		);
2779	}
2780
2781	/**
2782	 * @dataProvider provideTestPageFactory
2783	 */
2784	public function testResetNotificationTimestamp_futureNotificationTimestampForced( $testPageFactory ) {
2785		$user = new UserIdentityValue( 1, 'MockUser' );
2786		$oldid = 22;
2787		$title = $testPageFactory( 100, 0, 'SomeDbKey' );
2788
2789		$mockDb = $this->getMockDb();
2790		$mockDb->expects( $this->once() )
2791			->method( 'addQuotes' )
2792			->willReturn( '20200101000000' );
2793		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
2794		$mockDb->expects( $this->exactly( 2 ) )
2795			->method( 'makeList' )
2796			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
2797		$mockDb->expects( $this->once() )
2798			->method( 'select' )
2799			->with(
2800				[ 'watchlist', 'watchlist_expiry' ],
2801				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
2802				[
2803					'wl_user' => 1,
2804					$makeListSql,
2805					'we_expiry IS NULL OR we_expiry > 20200101000000'
2806				]
2807			)
2808			->willReturn( [
2809				(object)[
2810					'wl_namespace' => 0,
2811					'wl_title' => 'SomeDbKey',
2812					'wl_notificationtimestamp' => '30151212010101',
2813				]
2814			] );
2815
2816		$mockCache = $this->getMockCache();
2817		$mockCache->expects( $this->never() )->method( 'get' );
2818		$mockCache->expects( $this->once() )
2819			->method( 'set' )
2820			->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
2821		$mockCache->expects( $this->once() )
2822			->method( 'delete' )
2823			->with( '0:SomeDbKey:1' );
2824
2825		$mockQueueGroup = $this->getMockJobQueueGroup( false );
2826
2827		$mockRevision = $this->createNoOpMock( RevisionRecord::class );
2828		$mockNextRevision = $this->createNoOpMock( RevisionRecord::class );
2829
2830		$mockRevisionLookup = $this->getMockRevisionLookup(
2831			[
2832				'getTimestampFromId' => function ( $oldidParam ) use ( $oldid ) {
2833					$this->assertEquals( $oldid, $oldidParam );
2834				},
2835				'getRevisionById' => function ( $id ) use ( $oldid, $mockRevision ) {
2836					$this->assertSame( $oldid, $id );
2837					return $mockRevision;
2838				},
2839				'getNextRevision' =>
2840				function ( RevisionRecord $rev ) use ( $mockRevision, $mockNextRevision ) {
2841					$this->assertSame( $mockRevision, $rev );
2842					return $mockNextRevision;
2843				},
2844			],
2845			[
2846				'getTimestampFromId' => 2,
2847				'getRevisionById' => 1,
2848				'getNextRevision' => 1,
2849			]
2850		);
2851
2852		$titleFactory = $this->getTitleFactory( $title );
2853
2854		$store = $this->newWatchedItemStore( [
2855			'db' => $mockDb,
2856			'queueGroup' => $mockQueueGroup,
2857			'cache' => $mockCache,
2858			'revisionLookup' => $mockRevisionLookup,
2859			'titleFactory' => $titleFactory,
2860		] );
2861
2862		$mockQueueGroup->method( 'lazyPush' )
2863			->willReturnCallback(
2864				function ( ActivityUpdateJob $job ) use ( $title, $user ) {
2865					$this->verifyCallbackJob(
2866						$job,
2867						$title,
2868						$user->getId(),
2869						static function ( $time ) {
2870							return $time === '30151212010101';
2871						}
2872					);
2873				}
2874			);
2875
2876		$this->assertTrue(
2877			$store->resetNotificationTimestamp(
2878				$user,
2879				$title,
2880				'force',
2881				$oldid
2882			)
2883		);
2884	}
2885
2886	/**
2887	 * @dataProvider provideTestPageFactory
2888	 */
2889	public function testResetNotificationTimestamp_futureNotificationTimestampNotForced( $testPageFactory ) {
2890		$user = new UserIdentityValue( 1, 'MockUser' );
2891		$oldid = 22;
2892		$title = $testPageFactory( 100, 0, 'SomeDbKey' );
2893
2894		$mockDb = $this->getMockDb();
2895		$mockDb->expects( $this->once() )
2896			->method( 'addQuotes' )
2897			->willReturn( '20200101000000' );
2898		$makeListSql = "wl_namespace = 0 AND wl_title = 'SomeDbKey'";
2899		$mockDb->expects( $this->exactly( 2 ) )
2900			->method( 'makeList' )
2901			->willReturnOnConsecutiveCalls( $makeListSql, $makeListSql );
2902		$mockDb->expects( $this->once() )
2903			->method( 'select' )
2904			->with(
2905				[ 'watchlist', 'watchlist_expiry' ],
2906				[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp', 'we_expiry' ],
2907				[
2908					'wl_user' => 1,
2909					$makeListSql,
2910					'we_expiry IS NULL OR we_expiry > 20200101000000',
2911				]
2912			)
2913			->willReturn( [
2914				(object)[
2915					'wl_namespace' => 0,
2916					'wl_title' => 'SomeDbKey',
2917					'wl_notificationtimestamp' => '30151212010101',
2918				]
2919			] );
2920
2921		$mockCache = $this->getMockCache();
2922		$mockCache->expects( $this->never() )->method( 'get' );
2923		$mockCache->expects( $this->once() )
2924			->method( 'set' )
2925			->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
2926		$mockCache->expects( $this->once() )
2927			->method( 'delete' )
2928			->with( '0:SomeDbKey:1' );
2929
2930		$mockQueueGroup = $this->getMockJobQueueGroup( false );
2931
2932		$mockRevision = $this->createNoOpMock( RevisionRecord::class );
2933		$mockNextRevision = $this->createNoOpMock( RevisionRecord::class );
2934
2935		$mockRevisionLookup = $this->getMockRevisionLookup(
2936			[
2937				'getTimestampFromId' => function ( $oldidParam ) use ( $oldid ) {
2938					$this->assertEquals( $oldid, $oldidParam );
2939				},
2940				'getRevisionById' => function ( $id ) use ( $oldid, $mockRevision ) {
2941					$this->assertSame( $oldid, $id );
2942					return $mockRevision;
2943				},
2944				'getNextRevision' =>
2945				function ( RevisionRecord $rev ) use ( $mockRevision, $mockNextRevision ) {
2946					$this->assertSame( $mockRevision, $rev );
2947					return $mockNextRevision;
2948				},
2949			],
2950			[
2951				'getTimestampFromId' => 2,
2952				'getRevisionById' => 1,
2953				'getNextRevision' => 1,
2954			]
2955		);
2956
2957		$titleFactory = $this->getTitleFactory( $title );
2958
2959		$store = $this->newWatchedItemStore( [
2960			'db' => $mockDb,
2961			'queueGroup' => $mockQueueGroup,
2962			'cache' => $mockCache,
2963			'revisionLookup' => $mockRevisionLookup,
2964			'titleFactory' => $titleFactory,
2965		] );
2966
2967		$mockQueueGroup->method( 'lazyPush' )
2968			->willReturnCallback(
2969				function ( ActivityUpdateJob $job ) use ( $title, $user ) {
2970					$this->verifyCallbackJob(
2971						$job,
2972						$title,
2973						$user->getId(),
2974						static function ( $time ) {
2975							return $time === false;
2976						}
2977					);
2978				}
2979			);
2980
2981		$this->assertTrue(
2982			$store->resetNotificationTimestamp(
2983				$user,
2984				$title,
2985				'',
2986				$oldid
2987			)
2988		);
2989	}
2990
2991	public function testSetNotificationTimestampsForUser_anonUser() {
2992		$store = $this->newWatchedItemStore();
2993		$this->assertFalse( $store->setNotificationTimestampsForUser(
2994			new UserIdentityValue( 0, 'AnonUser' ), '' ) );
2995	}
2996
2997	public function testSetNotificationTimestampsForUser_allRows() {
2998		$user = new UserIdentityValue( 1, 'MockUser' );
2999		$timestamp = '20100101010101';
3000
3001		$store = $this->newWatchedItemStore();
3002
3003		// Note: This does not actually assert the job is correct
3004		$callableCallCounter = 0;
3005		$mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
3006			$callableCallCounter++;
3007			$this->assertIsCallable( $callable );
3008		};
3009		$scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
3010
3011		$this->assertTrue(
3012			$store->setNotificationTimestampsForUser( $user, $timestamp )
3013		);
3014		$this->assertSame( 1, $callableCallCounter );
3015	}
3016
3017	public function testSetNotificationTimestampsForUser_nullTimestamp() {
3018		$user = new UserIdentityValue( 1, 'MockUser' );
3019		$timestamp = null;
3020
3021		$store = $this->newWatchedItemStore();
3022
3023		// Note: This does not actually assert the job is correct
3024		$callableCallCounter = 0;
3025		$mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
3026			$callableCallCounter++;
3027			$this->assertIsCallable( $callable );
3028		};
3029		$scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
3030
3031		$this->assertTrue(
3032			$store->setNotificationTimestampsForUser( $user, $timestamp )
3033		);
3034	}
3035
3036	/**
3037	 * @dataProvider provideTestPageFactory
3038	 */
3039	public function testSetNotificationTimestampsForUser_specificTargets( $testPageFactory ) {
3040		$user = new UserIdentityValue( 1, 'MockUser' );
3041		$timestamp = '20100101010101';
3042		$targets = [ $testPageFactory( 100, 0, 'Foo' ), $testPageFactory( 101, 0, 'Bar' ) ];
3043
3044		$mockDb = $this->getMockDb();
3045		$mockDb->expects( $this->once() )
3046			->method( 'update' )
3047			->with(
3048				'watchlist',
3049				[ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
3050				[ 'wl_user' => 1, 'wl_namespace' => 0, 'wl_title' => [ 'Foo', 'Bar' ] ]
3051			)
3052			->willReturn( true );
3053		$mockDb->expects( $this->once() )
3054			->method( 'timestamp' )
3055			->willReturnCallback( static function ( $value ) {
3056				return 'TS' . $value . 'TS';
3057			} );
3058		$mockDb->expects( $this->once() )
3059			->method( 'affectedRows' )
3060			->willReturn( 2 );
3061
3062		$store = $this->newWatchedItemStore( [ 'db' => $mockDb ] );
3063
3064		$this->assertTrue(
3065			$store->setNotificationTimestampsForUser( $user, $timestamp, $targets )
3066		);
3067	}
3068
3069	/**
3070	 * @dataProvider provideTestPageFactory
3071	 */
3072	public function testUpdateNotificationTimestamp_watchersExist( $testPageFactory ) {
3073		$mockDb = $this->getMockDb();
3074		$mockDb->expects( $this->once() )
3075			->method( 'addQuotes' )
3076			->willReturn( '20200101000000' );
3077		$mockDb->expects( $this->once() )
3078			->method( 'selectFieldValues' )
3079			->with(
3080				[ 'watchlist', 'watchlist_expiry' ],
3081				'wl_user',
3082				[
3083					'wl_user != 1',
3084					'wl_namespace' => 0,
3085					'wl_title' => 'SomeDbKey',
3086					'wl_notificationtimestamp IS NULL',
3087					'we_expiry IS NULL OR we_expiry > 20200101000000',
3088				]
3089			)
3090			->willReturn( [ '2', '3' ] );
3091		$mockDb->expects( $this->once() )
3092			->method( 'update' )
3093			->with(
3094				'watchlist',
3095				[ 'wl_notificationtimestamp' => null ],
3096				[
3097					'wl_user' => [ 2, 3 ],
3098					'wl_namespace' => 0,
3099					'wl_title' => 'SomeDbKey',
3100				]
3101			);
3102
3103		$mockCache = $this->getMockCache();
3104		$mockCache->expects( $this->never() )->method( 'set' );
3105		$mockCache->expects( $this->never() )->method( 'get' );
3106		$mockCache->expects( $this->never() )->method( 'delete' );
3107
3108		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
3109
3110		// updateNotificationTimestamp calls DeferredUpdates::addCallableUpdate
3111		// in normal operation, but we want to test that update actually running, so
3112		// override it
3113		$mockCallback = function ( $callable, $stage, $dbw ) use ( $mockDb ) {
3114			$this->assertIsCallable( $callable );
3115			$this->assertSame( DeferredUpdates::POSTSEND, $stage );
3116			$this->assertSame( $mockDb, $dbw );
3117			( $callable )();
3118		};
3119		$scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
3120
3121		$this->assertEquals(
3122			[ 2, 3 ],
3123			$store->updateNotificationTimestamp(
3124				new UserIdentityValue( 1, 'MockUser' ),
3125				$testPageFactory( 100, 0, 'SomeDbKey' ),
3126				'20151212010101'
3127			)
3128		);
3129	}
3130
3131	/**
3132	 * @dataProvider provideTestPageFactory
3133	 */
3134	public function testUpdateNotificationTimestamp_noWatchers( $testPageFactory ) {
3135		$mockDb = $this->getMockDb();
3136		$mockDb->expects( $this->once() )
3137			->method( 'addQuotes' )
3138			->willReturn( '20200101000000' );
3139		$mockDb->expects( $this->once() )
3140			->method( 'selectFieldValues' )
3141			->with(
3142				[ 'watchlist', 'watchlist_expiry' ],
3143				'wl_user',
3144				[
3145					'wl_user != 1',
3146					'wl_namespace' => 0,
3147					'wl_title' => 'SomeDbKey',
3148					'wl_notificationtimestamp IS NULL',
3149					'we_expiry IS NULL OR we_expiry > 20200101000000',
3150				],
3151				'WatchedItemStore::updateNotificationTimestamp',
3152				[],
3153				[ 'watchlist_expiry' => [ 'LEFT JOIN', 'wl_id = we_item' ] ]
3154			)
3155			->willReturn( [] );
3156		$mockDb->expects( $this->never() )
3157			->method( 'update' );
3158
3159		$mockCache = $this->getMockCache();
3160		$mockCache->expects( $this->never() )->method( 'set' );
3161		$mockCache->expects( $this->never() )->method( 'get' );
3162		$mockCache->expects( $this->never() )->method( 'delete' );
3163
3164		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
3165
3166		$watchers = $store->updateNotificationTimestamp(
3167			new UserIdentityValue( 1, 'MockUser' ),
3168			$testPageFactory( 100, 0, 'SomeDbKey' ),
3169			'20151212010101'
3170		);
3171		$this->assertSame( [], $watchers );
3172	}
3173
3174	/**
3175	 * @dataProvider provideTestPageFactory
3176	 */
3177	public function testUpdateNotificationTimestamp_clearsCachedItems( $testPageFactory ) {
3178		$user = new UserIdentityValue( 1, 'MockUser' );
3179		$titleValue = $testPageFactory( 100, 0, 'SomeDbKey' );
3180
3181		$mockDb = $this->getMockDb();
3182		$mockDb->expects( $this->once() )
3183			->method( 'select' )
3184			->willReturn( [
3185				(object)[
3186					'wl_namespace' => 0,
3187					'wl_title' => 'SomeDbKey',
3188					'wl_notificationtimestamp' => '20151212010101'
3189				]
3190			] );
3191		$mockDb->expects( $this->once() )
3192			->method( 'selectFieldValues' )
3193			->willReturn( [ '2', '3' ] );
3194		$mockDb->expects( $this->once() )
3195			->method( 'update' );
3196
3197		$mockCache = $this->getMockCache();
3198		$mockCache->expects( $this->once() )
3199			->method( 'set' )
3200			->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
3201		$mockCache->expects( $this->once() )
3202			->method( 'get' )
3203			->with( '0:SomeDbKey:1' );
3204		$mockCache->expects( $this->once() )
3205			->method( 'delete' )
3206			->with( '0:SomeDbKey:1' );
3207
3208		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
3209
3210		// This will add the item to the cache
3211		$store->getWatchedItem( $user, $titleValue );
3212
3213		// updateNotificationTimestamp calls DeferredUpdates::addCallableUpdate
3214		// in normal operation, but we want to test that update actually running, so
3215		// override it
3216		$mockCallback = function ( $callable, $stage, $dbw ) use ( $mockDb ) {
3217			$this->assertIsCallable( $callable );
3218			$this->assertSame( DeferredUpdates::POSTSEND, $stage );
3219			$this->assertSame( $mockDb, $dbw );
3220			( $callable )();
3221		};
3222		$scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
3223
3224		$store->updateNotificationTimestamp(
3225			new UserIdentityValue( 1, 'MockUser' ),
3226			$titleValue,
3227			'20151212010101'
3228		);
3229	}
3230
3231	public function testRemoveExpired() {
3232		$mockDb = $this->getMockDb();
3233
3234		// addQuotes is used for the expiry value.
3235		$mockDb->expects( $this->once() )
3236			->method( 'addQuotes' )
3237			->willReturn( '20200101000000' );
3238
3239		// Select watchlist IDs.
3240		$mockDb->expects( $this->exactly( 2 ) )
3241			->method( 'selectFieldValues' )
3242			->withConsecutive(
3243				// Select expired items.
3244				[
3245					'watchlist_expiry',
3246					'we_item',
3247					[ 'we_expiry <= 20200101000000' ],
3248					'WatchedItemStore::removeExpired',
3249					[ 'LIMIT' => 2 ]
3250				],
3251				// Select orphaned items.
3252				[
3253					[ 'watchlist_expiry', 'watchlist' ],
3254					'we_item',
3255					[ 'wl_id' => null, 'we_expiry' => null ],
3256					'WatchedItemStore::removeExpired',
3257					[],
3258					[ 'watchlist' => [ 'LEFT JOIN', 'wl_id = we_item' ] ]
3259				]
3260			)
3261			->willReturnOnConsecutiveCalls(
3262				[ 1, 2 ],
3263				[ 3 ]
3264			);
3265
3266		// Return whatever is passed to makeList, to be tested below.
3267		$mockDb->expects( $this->once() )
3268			->method( 'makeList' )
3269			->willReturnArgument( 0 );
3270
3271		// Delete from watchlist and watchlist_expiry.
3272		$mockDb->expects( $this->exactly( 3 ) )
3273			->method( 'delete' )
3274			->withConsecutive(
3275				// Delete expired items from watchlist
3276				[
3277					'watchlist',
3278					[ 'wl_id' => [ 1, 2 ] ],
3279					'WatchedItemStore::removeExpired'
3280				],
3281				// Delete expired items from watchlist_expiry
3282				[
3283					'watchlist_expiry',
3284					[ 'we_item' => [ 1, 2 ] ],
3285					'WatchedItemStore::removeExpired'
3286				],
3287				// Delete orphaned items
3288				[
3289					'watchlist_expiry',
3290					[ 'we_item' => [ 3 ] ],
3291					'WatchedItemStore::removeExpired'
3292				]
3293			);
3294
3295		$mockCache = $this->getMockCache();
3296		$store = $this->newWatchedItemStore( [ 'db' => $mockDb, 'cache' => $mockCache ] );
3297		$store->removeExpired( 2, true );
3298	}
3299}
3300