1<?php
2
3use MediaWiki\MediaWikiServices;
4use Wikimedia\TestingAccessWrapper;
5use Wikimedia\Timestamp\ConvertibleTimestamp;
6
7/**
8 * @author Addshore
9 *
10 * @group Database
11 *
12 * @covers WatchedItemStore
13 */
14class WatchedItemStoreIntegrationTest extends MediaWikiIntegrationTestCase {
15
16	protected function setUp() : void {
17		parent::setUp();
18		self::$users['WatchedItemStoreIntegrationTestUser']
19			= new TestUser( 'WatchedItemStoreIntegrationTestUser' );
20
21		$this->setMwGlobals( [
22			'wgWatchlistExpiry' => true,
23			'$wgWatchlistExpiryMaxDuration' => '6 months',
24		] );
25	}
26
27	private function getUser() {
28		return self::$users['WatchedItemStoreIntegrationTestUser']->getUser();
29	}
30
31	public function testWatchAndUnWatchItem() {
32		$user = $this->getUser();
33		$title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
34		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
35		// Cleanup after previous tests
36		$store->removeWatch( $user, $title );
37		$initialWatchers = $store->countWatchers( $title );
38		$initialUserWatchedItems = $store->countWatchedItems( $user );
39
40		$this->assertFalse(
41			$store->isWatched( $user, $title ),
42			'Page should not initially be watched'
43		);
44		$this->assertFalse( $store->isTempWatched( $user, $title ) );
45
46		$store->addWatch( $user, $title );
47		$this->assertTrue(
48			$store->isWatched( $user, $title ),
49			'Page should be watched'
50		);
51		$this->assertFalse(
52			$store->isTempWatched( $user, $title ),
53			'Page should not be temporarily watched'
54		);
55		$this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
56		$watchedItemsForUser = $store->getWatchedItemsForUser( $user );
57		$this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser );
58		$watchedItemsForUserHasExpectedItem = false;
59		foreach ( $watchedItemsForUser as $watchedItem ) {
60			if (
61				$watchedItem->getUser()->equals( $user ) &&
62				$watchedItem->getLinkTarget() == $title->getTitleValue()
63			) {
64				$watchedItemsForUserHasExpectedItem = true;
65			}
66		}
67		$this->assertTrue(
68			$watchedItemsForUserHasExpectedItem,
69			'getWatchedItemsForUser should contain the page'
70		);
71		$this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) );
72		$this->assertEquals(
73			$initialWatchers + 1,
74			$store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
75		);
76		$this->assertEquals(
77			[ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ],
78			$store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] )
79		);
80		$this->assertEquals(
81			[ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
82			$store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] )
83		);
84		$this->assertEquals(
85			[ $title->getNamespace() => [ $title->getDBkey() => null ] ],
86			$store->getNotificationTimestampsBatch( $user, [ $title ] )
87		);
88
89		$store->removeWatch( $user, $title );
90		$this->assertFalse(
91			$store->isWatched( $user, $title ),
92			'Page should be unwatched'
93		);
94		$this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );
95		$watchedItemsForUser = $store->getWatchedItemsForUser( $user );
96		$this->assertCount( $initialUserWatchedItems, $watchedItemsForUser );
97		$watchedItemsForUserHasExpectedItem = false;
98		foreach ( $watchedItemsForUser as $watchedItem ) {
99			if (
100				$watchedItem->getUser()->equals( $user ) &&
101				$watchedItem->getLinkTarget() == $title->getTitleValue()
102			) {
103				$watchedItemsForUserHasExpectedItem = true;
104			}
105		}
106		$this->assertFalse(
107			$watchedItemsForUserHasExpectedItem,
108			'getWatchedItemsForUser should not contain the page'
109		);
110		$this->assertEquals( $initialWatchers, $store->countWatchers( $title ) );
111		$this->assertEquals(
112			$initialWatchers,
113			$store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
114		);
115		$this->assertEquals(
116			[ $title->getNamespace() => [ $title->getDBkey() => false ] ],
117			$store->getNotificationTimestampsBatch( $user, [ $title ] )
118		);
119	}
120
121	public function testWatchAndUnWatchItemWithExpiry(): void {
122		$user = $this->getUser();
123		$title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
124		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
125		$initialUserWatchedItems = $store->countWatchedItems( $user );
126
127		// Watch for a duration greater than the max ($wgWatchlistExpiryMaxDuration),
128		// which should get changed to the max.
129		$expiry = wfTimestamp( TS_MW, strtotime( '10 years' ) );
130		$store->addWatch( $user, $title, $expiry );
131		$this->assertLessThanOrEqual(
132			wfTimestamp( TS_MW, strtotime( '6 months' ) ),
133			$store->loadWatchedItem( $user, $title )->getExpiry()
134		);
135
136		// Valid expiry that's less than the max.
137		$expiry = wfTimestamp( TS_MW, strtotime( '1 week' ) );
138
139		$store->addWatch( $user, $title, $expiry );
140		$this->assertSame(
141			$expiry,
142			$store->loadWatchedItem( $user, $title )->getExpiry()
143		);
144		$this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
145		$this->assertTrue( $store->isTempWatched( $user, $title ) );
146
147		// Invalid expiry, nothing should change.
148		$exceptionThrown = false;
149		try {
150			$store->addWatch( $user, $title, 'invalid expiry' );
151		} catch ( InvalidArgumentException $exception ) {
152			$exceptionThrown = true;
153			// Asserting watchedItem getExpiry stays unchanged
154			$this->assertSame(
155				$expiry,
156				$store->loadWatchedItem( $user, $title )->getExpiry()
157			);
158			$this->assertSame(
159				$initialUserWatchedItems + 1,
160				$store->countWatchedItems( $user )
161			);
162		}
163		$this->assertTrue( $exceptionThrown );
164
165		// Changed to infinity, so expiry row should be removed.
166		$store->addWatch( $user, $title, 'infinity' );
167		$this->assertNull(
168			$store->loadWatchedItem( $user, $title )->getExpiry()
169		);
170		$this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
171		$this->assertFalse( $store->isTempWatched( $user, $title ) );
172
173		// Updating to a valid expiry.
174		$store->addWatch( $user, $title, '1 month' );
175		$this->assertLessThanOrEqual(
176			strtotime( '1 month' ),
177			wfTimestamp(
178				TS_UNIX,
179				$store->loadWatchedItem( $user, $title )->getExpiry()
180			)
181		);
182		$this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
183
184		// Expiry in the past, should not be considered watched.
185		$store->addWatch( $user, $title, '20090101000000' );
186		$this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );
187
188		// Test isWatch(), which would normally pull from the cache. In this case
189		// the cache should bust and return false since the item has expired.
190		$this->assertFalse( $store->isWatched( $user, $title ) );
191		$this->assertFalse( $store->isTempWatched( $user, $title ) );
192	}
193
194	public function testWatchAndUnwatchMultipleWithExpiry(): void {
195		$user = $this->getUser();
196		$title1 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' );
197		$title2 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' );
198		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
199
200		// Use a relative timestamp in the near future to ensure we don't exceed the max.
201		// See testWatchAndUnWatchItemWithExpiry() for tests regarding the max duration.
202		$timestamp = wfTimestamp( TS_MW, strtotime( '1 week' ) );
203		$store->addWatchBatchForUser( $user, [ $title1, $title2 ], $timestamp );
204
205		$this->assertSame(
206			$timestamp,
207			$store->loadWatchedItem( $user, $title1 )->getExpiry()
208		);
209		$this->assertSame(
210			$timestamp,
211			$store->loadWatchedItem( $user, $title2 )->getExpiry()
212		);
213
214		// Clear expiries.
215		$store->addWatchBatchForUser( $user, [ $title1, $title2 ], 'infinity' );
216
217		$this->assertNull(
218			$store->loadWatchedItem( $user, $title1 )->getExpiry()
219		);
220		$this->assertNull(
221			$store->loadWatchedItem( $user, $title2 )->getExpiry()
222		);
223	}
224
225	public function testWatchBatchAndClearItems() {
226		$user = $this->getUser();
227		$title1 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' );
228		$title2 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage2' );
229		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
230
231		$store->addWatchBatchForUser( $user, [ $title1, $title2 ] );
232
233		$this->assertTrue( $store->isWatched( $user, $title1 ) );
234		$this->assertTrue( $store->isWatched( $user, $title2 ) );
235
236		$store->clearUserWatchedItems( $user );
237
238		$this->assertFalse( $store->isWatched( $user, $title1 ) );
239		$this->assertFalse( $store->isWatched( $user, $title2 ) );
240	}
241
242	public function testUpdateResetAndSetNotificationTimestamp() {
243		$user = $this->getUser();
244		$otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser();
245		$title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
246		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
247		$store->addWatch( $user, $title );
248		$this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() );
249		$initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' );
250		$initialUnreadNotifications = $store->countUnreadNotifications( $user );
251
252		$store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' );
253		$this->assertSame(
254			'20150202010101',
255			$store->loadWatchedItem( $user, $title )->getNotificationTimestamp()
256		);
257		$this->assertEquals(
258			[ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ],
259			$store->getNotificationTimestampsBatch( $user, [ $title ] )
260		);
261		$this->assertEquals(
262			$initialVisitingWatchers - 1,
263			$store->countVisitingWatchers( $title, '20150202020202' )
264		);
265		$this->assertEquals(
266			$initialVisitingWatchers - 1,
267			$store->countVisitingWatchersMultiple(
268				[ [ $title, '20150202020202' ] ]
269			)[$title->getNamespace()][$title->getDBkey()]
270		);
271		$this->assertEquals(
272			$initialUnreadNotifications + 1,
273			$store->countUnreadNotifications( $user )
274		);
275		$this->assertSame(
276			true,
277			$store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 )
278		);
279
280		$this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) );
281		$this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() );
282		$this->assertEquals(
283			[ $title->getNamespace() => [ $title->getDBkey() => null ] ],
284			$store->getNotificationTimestampsBatch( $user, [ $title ] )
285		);
286
287		// Run the job queue
288		JobQueueGroup::destroySingletons();
289		$jobs = new RunJobs;
290		$jobs->loadParamsAndArgs( null, [ 'quiet' => true ], null );
291		$jobs->execute();
292
293		$this->assertEquals(
294			$initialVisitingWatchers,
295			$store->countVisitingWatchers( $title, '20150202020202' )
296		);
297		$this->assertEquals(
298			$initialVisitingWatchers,
299			$store->countVisitingWatchersMultiple(
300				[ [ $title, '20150202020202' ] ]
301			)[$title->getNamespace()][$title->getDBkey()]
302		);
303		$this->assertEquals(
304			[ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ],
305			$store->countVisitingWatchersMultiple(
306				[ [ $title, '20150202020202' ] ], $initialVisitingWatchers
307			)
308		);
309		$this->assertEquals(
310			[ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
311			$store->countVisitingWatchersMultiple(
312				[ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1
313			)
314		);
315
316		// setNotificationTimestampsForUser specifying a title
317		$this->assertTrue(
318			$store->setNotificationTimestampsForUser( $user, '20100202020202', [ $title ] )
319		);
320		$this->assertSame(
321			'20100202020202',
322			$store->getWatchedItem( $user, $title )->getNotificationTimestamp()
323		);
324
325		// setNotificationTimestampsForUser not specifying a title
326		// This will try to use a DeferredUpdate; disable that
327		$mockCallback = function ( $callback ) {
328			$callback();
329		};
330		$scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
331		$this->assertTrue(
332			$store->setNotificationTimestampsForUser( $user, '20110202020202' )
333		);
334		// Because the operation above is normally deferred, it doesn't clear the cache
335		// Clear the cache manually
336		$wrappedStore = TestingAccessWrapper::newFromObject( $store );
337		$wrappedStore->uncacheUser( $user );
338		$this->assertSame(
339			'20110202020202',
340			$store->getWatchedItem( $user, $title )->getNotificationTimestamp()
341		);
342	}
343
344	public function testDuplicateAllAssociatedEntries() {
345		// Fake current time to be 2020-05-27T00:00:00Z
346		ConvertibleTimestamp::setFakeTime( '20200527000000' );
347
348		$user = $this->getUser();
349		$titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' );
350		$titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' );
351		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
352		$store->addWatch( $user, $titleOld->getSubjectPage(), '99990123000000' );
353		$store->addWatch( $user, $titleOld->getTalkPage(), '99990123000000' );
354
355		// Fetch stored expiry (may have changed due to wgWatchlistExpiryMaxDuration).
356		$expectedExpiry = $store->getWatchedItem( $user, $titleOld )->getExpiry();
357
358		// Use the standard test user as well, so we can test that each user's
359		// respective expiry is correctly copied.
360		$user2 = $this->getTestSysop()->getUser();
361		$store->addWatch( $user2, $titleOld->getSubjectPage(), '1 week' );
362		$store->addWatch( $user2, $titleOld->getTalkPage(), '1 week' );
363		$expectedExpiry2 = $store->getWatchedItem( $user2, $titleOld )->getExpiry();
364
365		// Cleanup after previous tests
366		$store->removeWatch( $user, $titleNew->getSubjectPage() );
367		$store->removeWatch( $user, $titleNew->getTalkPage() );
368
369		// Duplicate associated entries. This will try to use a DeferredUpdate; disable that.
370		$mockCallback = function ( $callback ) {
371			$callback();
372		};
373		$store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
374		$store->duplicateAllAssociatedEntries( $titleOld, $titleNew );
375
376		$this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) );
377		$this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) );
378		$this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) );
379		$this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) );
380
381		$oldExpiry = $store->getWatchedItem( $user, $titleOld )->getExpiry();
382		$newExpiry = $store->getWatchedItem( $user, $titleNew )->getExpiry();
383		$this->assertSame( $expectedExpiry, $oldExpiry );
384		$this->assertSame( $expectedExpiry, $newExpiry );
385
386		// Same for $user2 and $expectedExpiry2
387		$oldExpiry = $store->getWatchedItem( $user2, $titleOld )->getExpiry();
388		$newExpiry = $store->getWatchedItem( $user2, $titleNew )->getExpiry();
389		$this->assertSame( $expectedExpiry2, $oldExpiry );
390		$this->assertSame( $expectedExpiry2, $newExpiry );
391	}
392
393	public function testRemoveExpired() {
394		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
395
396		// Clear out any expired rows, to start from a known point.
397		$store->removeExpired( 10 );
398		$this->assertSame( 0, $store->countExpired() );
399
400		// Add three pages, two of which have already expired.
401		$user = $this->getUser();
402		$store->addWatch( $user, Title::newFromText( 'P1' ), '2020-01-25' );
403		$store->addWatch( $user, Title::newFromText( 'P2' ), '20200101000000' );
404		$store->addWatch( $user, Title::newFromText( 'P3' ), '1 month' );
405
406		// Test that they can be counted and removed correctly.
407		$this->assertSame( 2, $store->countExpired() );
408		$store->removeExpired( 1 );
409		$this->assertSame( 1, $store->countExpired() );
410	}
411
412	public function testRemoveOrphanedExpired() {
413		$store = MediaWikiServices::getInstance()->getWatchedItemStore();
414		// Clear out any expired rows, to start from a known point.
415		$store->removeExpired( 10 );
416
417		// Manually insert some orphaned non-expired rows.
418		$orphanRows = [
419			[ 'we_item' => '100000', 'we_expiry' => $this->db->timestamp( '30300101000000' ) ],
420			[ 'we_item' => '100001', 'we_expiry' => $this->db->timestamp( '30300101000000' ) ],
421		];
422		$this->db->insert( 'watchlist_expiry', $orphanRows, __METHOD__ );
423		$initialRowCount = $this->db->selectRowCount( 'watchlist_expiry', '*', [], __METHOD__ );
424
425		// Make sure the orphans aren't removed if it's not requested.
426		$store->removeExpired( 10, false );
427		$this->assertSame(
428			$initialRowCount,
429			$this->db->selectRowCount( 'watchlist_expiry', '*', [], __METHOD__ )
430		);
431
432		// Make sure they are removed when requested.
433		$store->removeExpired( 10, true );
434		$this->assertSame(
435			$initialRowCount - 2,
436			$this->db->selectRowCount( 'watchlist_expiry', '*', [], __METHOD__ )
437		);
438	}
439}
440