summaryrefslogtreecommitdiff
path: root/www/wiki/tests/phpunit/includes/watcheditem
diff options
context:
space:
mode:
Diffstat (limited to 'www/wiki/tests/phpunit/includes/watcheditem')
-rw-r--r--www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php246
-rw-r--r--www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php1706
-rw-r--r--www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php231
-rw-r--r--www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php2753
4 files changed, 4936 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php
new file mode 100644
index 00000000..a8761e39
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/watcheditem/NoWriteWatchedItemStoreUnitTest.php
@@ -0,0 +1,246 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @covers NoWriteWatchedItemStore
+ */
+class NoWriteWatchedItemStoreUnitTest extends MediaWikiTestCase {
+
+ public function testAddWatch() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'addWatch' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->addWatch( $this->getTestSysop()->getUser(), new TitleValue( 0, 'Foo' ) );
+ }
+
+ public function testAddWatchBatchForUser() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'addWatchBatchForUser' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->addWatchBatchForUser( $this->getTestSysop()->getUser(), [] );
+ }
+
+ public function testRemoveWatch() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'removeWatch' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->removeWatch( $this->getTestSysop()->getUser(), new TitleValue( 0, 'Foo' ) );
+ }
+
+ public function testSetNotificationTimestampsForUser() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'setNotificationTimestampsForUser' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->setNotificationTimestampsForUser(
+ $this->getTestSysop()->getUser(),
+ 'timestamp',
+ []
+ );
+ }
+
+ public function testUpdateNotificationTimestamp() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'updateNotificationTimestamp' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->updateNotificationTimestamp(
+ $this->getTestSysop()->getUser(),
+ new TitleValue( 0, 'Foo' ),
+ 'timestamp'
+ );
+ }
+
+ public function testResetNotificationTimestamp() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->never() )->method( 'resetNotificationTimestamp' );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->resetNotificationTimestamp(
+ $this->getTestSysop()->getUser(),
+ Title::newFromText( 'Foo' )
+ );
+ }
+
+ public function testCountWatchedItems() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'countWatchedItems' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countWatchedItems(
+ $this->getTestSysop()->getUser()
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountWatchers() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'countWatchers' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countWatchers(
+ new TitleValue( 0, 'Foo' )
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountVisitingWatchers() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'countVisitingWatchers' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countVisitingWatchers(
+ new TitleValue( 0, 'Foo' ),
+ 9
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountWatchersMultiple() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'countVisitingWatchersMultiple' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countWatchersMultiple(
+ [ new TitleValue( 0, 'Foo' ) ],
+ []
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountVisitingWatchersMultiple() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'countVisitingWatchersMultiple' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countVisitingWatchersMultiple(
+ [ [ new TitleValue( 0, 'Foo' ), 99 ] ],
+ 11
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testGetWatchedItem() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'getWatchedItem' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->getWatchedItem(
+ $this->getTestSysop()->getUser(),
+ new TitleValue( 0, 'Foo' )
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testLoadWatchedItem() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'loadWatchedItem' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->loadWatchedItem(
+ $this->getTestSysop()->getUser(),
+ new TitleValue( 0, 'Foo' )
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testGetWatchedItemsForUser() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'getWatchedItemsForUser' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->getWatchedItemsForUser(
+ $this->getTestSysop()->getUser(),
+ []
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testIsWatched() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )->method( 'isWatched' )->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->isWatched(
+ $this->getTestSysop()->getUser(),
+ new TitleValue( 0, 'Foo' )
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testGetNotificationTimestampsBatch() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'getNotificationTimestampsBatch' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->getNotificationTimestampsBatch(
+ $this->getTestSysop()->getUser(),
+ [ new TitleValue( 0, 'Foo' ) ]
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testCountUnreadNotifications() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $innerService->expects( $this->once() )
+ ->method( 'countUnreadNotifications' )
+ ->willReturn( __METHOD__ );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $return = $noWriteService->countUnreadNotifications(
+ $this->getTestSysop()->getUser(),
+ 88
+ );
+ $this->assertEquals( __METHOD__, $return );
+ }
+
+ public function testDuplicateAllAssociatedEntries() {
+ /** @var WatchedItemStoreInterface|PHPUnit_Framework_MockObject_MockObject $innerService */
+ $innerService = $this->getMockForAbstractClass( WatchedItemStoreInterface::class );
+ $noWriteService = new NoWriteWatchedItemStore( $innerService );
+
+ $this->setExpectedException( DBReadOnlyError::class );
+ $noWriteService->duplicateAllAssociatedEntries(
+ new TitleValue( 0, 'Foo' ),
+ new TitleValue( 0, 'Bar' )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php
new file mode 100644
index 00000000..50e6c202
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php
@@ -0,0 +1,1706 @@
+<?php
+
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers WatchedItemQueryService
+ */
+class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|CommentStore
+ */
+ private function getMockCommentStore() {
+ $mockStore = $this->getMockBuilder( CommentStore::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockStore->expects( $this->any() )
+ ->method( 'getFields' )
+ ->willReturn( [ 'commentstore' => 'fields' ] );
+ $mockStore->expects( $this->any() )
+ ->method( 'getJoin' )
+ ->willReturn( [
+ 'tables' => [ 'commentstore' => 'table' ],
+ 'fields' => [ 'commentstore' => 'field' ],
+ 'joins' => [ 'commentstore' => 'join' ],
+ ] );
+ return $mockStore;
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|ActorMigration
+ */
+ private function getMockActorMigration() {
+ $mockStore = $this->getMockBuilder( ActorMigration::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mockStore->expects( $this->any() )
+ ->method( 'getJoin' )
+ ->willReturn( [
+ 'tables' => [ 'actormigration' => 'table' ],
+ 'fields' => [
+ 'rc_user' => 'actormigration_user',
+ 'rc_user_text' => 'actormigration_user_text',
+ 'rc_actor' => 'actormigration_actor',
+ ],
+ 'joins' => [ 'actormigration' => 'join' ],
+ ] );
+ $mockStore->expects( $this->any() )
+ ->method( 'getWhere' )
+ ->willReturn( [
+ 'tables' => [ 'actormigration' => 'table' ],
+ 'conds' => 'actormigration_conds',
+ 'joins' => [ 'actormigration' => 'join' ],
+ ] );
+ $mockStore->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->willReturn( 'actormigration is anon' );
+ $mockStore->expects( $this->any() )
+ ->method( 'isNotAnon' )
+ ->willReturn( 'actormigration is not anon' );
+ return $mockStore;
+ }
+
+ /**
+ * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb
+ * @return WatchedItemQueryService
+ */
+ private function newService( $mockDb ) {
+ return new WatchedItemQueryService(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCommentStore(),
+ $this->getMockActorMigration()
+ );
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|Database
+ */
+ private function getMockDb() {
+ $mock = $this->getMockBuilder( Database::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $mock->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ $conds = [];
+ foreach ( $a as $k => $v ) {
+ if ( is_int( $k ) ) {
+ $conds[] = "($v)";
+ } elseif ( is_array( $v ) ) {
+ $conds[] = "($k IN ('" . implode( "','", $v ) . "'))";
+ } else {
+ $conds[] = "($k = '$v')";
+ }
+ }
+ return implode( $sqlConj, $conds );
+ } ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'timestamp' )
+ ->will( $this->returnArgument( 0 ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'bitAnd' )
+ ->willReturnCallback( function ( $a, $b ) {
+ return "($a & $b)";
+ } );
+
+ return $mock;
+ }
+
+ /**
+ * @param PHPUnit_Framework_MockObject_MockObject|Database $mockDb
+ * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer( $mockDb ) {
+ $mock = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->with( DB_REPLICA )
+ ->will( $this->returnValue( $mockDb ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithId( $id ) {
+ $mock = $this->getMockBuilder( User::class )->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( $id ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockUnrestrictedNonAnonUserWithId( $id ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'useRCPatrol' )
+ ->will( $this->returnValue( true ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @param string $notAllowedAction
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithIdAndRestrictedPermissions( $id, $notAllowedAction ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnCallback( function ( $action ) use ( $notAllowedAction ) {
+ return $action !== $notAllowedAction;
+ } ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnCallback( function () use ( $notAllowedAction ) {
+ $actions = func_get_args();
+ return !in_array( $notAllowedAction, $actions );
+ } ) );
+
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithIdAndNoPatrolRights( $id ) {
+ $mock = $this->getMockNonAnonUserWithId( $id );
+
+ $mock->expects( $this->any() )
+ ->method( 'isAllowed' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'isAllowedAny' )
+ ->will( $this->returnValue( true ) );
+
+ $mock->expects( $this->any() )
+ ->method( 'useRCPatrol' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'useNPPatrol' )
+ ->will( $this->returnValue( false ) );
+
+ return $mock;
+ }
+
+ private function getMockAnonUser() {
+ $mock = $this->getMockBuilder( User::class )->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( true ) );
+ return $mock;
+ }
+
+ private function getFakeRow( array $rowValues ) {
+ $fakeRow = new stdClass();
+ foreach ( $rowValues as $valueName => $value ) {
+ $fakeRow->$valueName = $value;
+ }
+ return $fakeRow;
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ [
+ 'wl_user' => 1,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ ],
+ $this->isType( 'string' ),
+ [
+ 'LIMIT' => 3,
+ ],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 3,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo3',
+ 'rc_timestamp' => '20151212010103',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $startFrom = null;
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user, [ 'limit' => 2 ], $startFrom
+ );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+
+ foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertInternalType( 'array', $recentChangeInfo );
+ }
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ ],
+ $items[0][1]
+ );
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ ],
+ $items[1][1]
+ );
+
+ $this->assertEquals( [ '20151212010103', 3 ], $startFrom );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_extension() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page', 'extension_dummy_table' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ 'extension_dummy_field',
+ ],
+ [
+ 'wl_user' => 1,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ 'extension_dummy_cond',
+ ],
+ $this->isType( 'string' ),
+ [
+ 'extension_dummy_option',
+ ],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ 'extension_dummy_join_cond' => [],
+ ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $mockExtension = $this->getMockBuilder( WatchedItemQueryServiceExtension::class )
+ ->getMock();
+ $mockExtension->expects( $this->once() )
+ ->method( 'modifyWatchedItemsWithRCInfoQuery' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->isType( 'array' ),
+ $this->isInstanceOf( IDatabase::class ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnCallback( function (
+ $user, $options, $db, &$tables, &$fields, &$conds, &$dbOptions, &$joinConds
+ ) {
+ $tables[] = 'extension_dummy_table';
+ $fields[] = 'extension_dummy_field';
+ $conds[] = 'extension_dummy_cond';
+ $dbOptions[] = 'extension_dummy_option';
+ $joinConds['extension_dummy_join_cond'] = [];
+ } ) );
+ $mockExtension->expects( $this->once() )
+ ->method( 'modifyWatchedItemsWithRCInfo' )
+ ->with(
+ $this->identicalTo( $user ),
+ $this->isType( 'array' ),
+ $this->isInstanceOf( IDatabase::class ),
+ $this->isType( 'array' ),
+ $this->anything(),
+ $this->anything() // Can't test for null here, PHPUnit applies this after the callback
+ )
+ ->will( $this->returnCallback( function ( $user, $options, $db, &$items, $res, &$startFrom ) {
+ foreach ( $items as $i => &$item ) {
+ $item[1]['extension_dummy_field'] = $i;
+ }
+ unset( $item );
+
+ $this->assertNull( $startFrom );
+ $startFrom = [ '20160203123456', 42 ];
+ } ) );
+
+ $queryService = $this->newService( $mockDb );
+ TestingAccessWrapper::newFromObject( $queryService )->extensions = [ $mockExtension ];
+
+ $startFrom = null;
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user, [], $startFrom
+ );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+
+ foreach ( $items as list( $watchedItem, $recentChangeInfo ) ) {
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertInternalType( 'array', $recentChangeInfo );
+ }
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 1,
+ 'rc_namespace' => 0,
+ 'rc_title' => 'Foo1',
+ 'rc_timestamp' => '20151212010101',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'extension_dummy_field' => 0,
+ ],
+ $items[0][1]
+ );
+
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1][0]
+ );
+ $this->assertEquals(
+ [
+ 'rc_id' => 2,
+ 'rc_namespace' => 1,
+ 'rc_title' => 'Foo2',
+ 'rc_timestamp' => '20151212010102',
+ 'rc_type' => RC_NEW,
+ 'rc_deleted' => 0,
+ 'extension_dummy_field' => 1,
+ ],
+ $items[1][1]
+ );
+
+ $this->assertEquals( [ '20160203123456', 42 ], $startFrom );
+ }
+
+ public function getWatchedItemsWithRecentChangeInfoOptionsProvider() {
+ return [
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_FLAGS ] ],
+ null,
+ [],
+ [ 'rc_type', 'rc_minor', 'rc_bot' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER ] ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [ 'rc_user_text' => 'actormigration_user_text' ],
+ [],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_USER_ID ] ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [ 'rc_user' => 'actormigration_user' ],
+ [],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_COMMENT ] ],
+ null,
+ [ 'commentstore' => 'table' ],
+ [ 'commentstore' => 'field' ],
+ [],
+ [],
+ [ 'commentstore' => 'join' ],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_PATROL_INFO ] ],
+ null,
+ [],
+ [ 'rc_patrolled', 'rc_log_type' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_SIZES ] ],
+ null,
+ [],
+ [ 'rc_old_len', 'rc_new_len' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'includeFields' => [ WatchedItemQueryService::INCLUDE_LOG_INFO ] ],
+ null,
+ [],
+ [ 'rc_logid', 'rc_log_type', 'rc_log_action', 'rc_params' ],
+ [],
+ [],
+ [],
+ ],
+ [
+ [ 'namespaceIds' => [ 0, 1 ] ],
+ null,
+ [],
+ [],
+ [ 'wl_namespace' => [ 0, 1 ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ] ],
+ null,
+ [],
+ [],
+ [ 'wl_namespace' => [ 0, 1 ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'rcTypes' => [ RC_EDIT, RC_NEW ] ],
+ null,
+ [],
+ [],
+ [ 'rc_type' => [ RC_EDIT, RC_NEW ] ],
+ [],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ null,
+ [],
+ [],
+ [],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+ null,
+ [],
+ [],
+ [],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'start' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER, 'end' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [
+ 'dir' => WatchedItemQueryService::DIR_OLDER,
+ 'start' => '20151212020101',
+ 'end' => '20151212010101'
+ ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212020101'", "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'start' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER, 'end' => '20151212010101' ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp <= '20151212010101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [
+ 'dir' => WatchedItemQueryService::DIR_NEWER,
+ 'start' => '20151212010101',
+ 'end' => '20151212020101'
+ ],
+ null,
+ [],
+ [],
+ [ "rc_timestamp >= '20151212010101'", "rc_timestamp <= '20151212020101'" ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'limit' => 10 ],
+ null,
+ [],
+ [],
+ [],
+ [ 'LIMIT' => 11 ],
+ [],
+ ],
+ [
+ [ 'limit' => "10; DROP TABLE watchlist;\n--" ],
+ null,
+ [],
+ [],
+ [],
+ [ 'LIMIT' => 11 ],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_MINOR ] ],
+ null,
+ [],
+ [],
+ [ 'rc_minor != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_MINOR ] ],
+ null,
+ [],
+ [],
+ [ 'rc_minor = 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_BOT ] ],
+ null,
+ [],
+ [],
+ [ 'rc_bot != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_BOT ] ],
+ null,
+ [],
+ [],
+ [ 'rc_bot = 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_ANON ] ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [],
+ [ 'actormigration is anon' ],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_ANON ] ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [],
+ [ 'actormigration is not anon' ],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_PATROLLED ] ],
+ null,
+ [],
+ [],
+ [ 'rc_patrolled != 0' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_PATROLLED ] ],
+ null,
+ [],
+ [],
+ [ 'rc_patrolled' => 0 ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_UNREAD ] ],
+ null,
+ [],
+ [],
+ [ 'rc_timestamp >= wl_notificationtimestamp' ],
+ [],
+ [],
+ ],
+ [
+ [ 'filters' => [ WatchedItemQueryService::FILTER_NOT_UNREAD ] ],
+ null,
+ [],
+ [],
+ [ 'wl_notificationtimestamp IS NULL OR rc_timestamp < wl_notificationtimestamp' ],
+ [],
+ [],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [],
+ [ 'actormigration_conds' ],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'notByUser' => 'SomeOtherUser' ],
+ null,
+ [ 'actormigration' => 'table' ],
+ [],
+ [ 'NOT(actormigration_conds)' ],
+ [],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', 123 ],
+ [],
+ [],
+ [
+ "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_NEWER ],
+ [ '20151212010101', 123 ],
+ [],
+ [],
+ [
+ "(rc_timestamp > '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id >= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp', 'rc_id' ] ],
+ [],
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', "123; DROP TABLE watchlist;\n--" ],
+ [],
+ [],
+ [
+ "(rc_timestamp < '20151212010101') OR ((rc_timestamp = '20151212010101') AND (rc_id <= 123))"
+ ],
+ [ 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] ],
+ [],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsWithRecentChangeInfoOptionsProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_optionsAndEmptyResult(
+ array $options,
+ $startFrom,
+ array $expectedExtraTables,
+ array $expectedExtraFields,
+ array $expectedExtraConds,
+ array $expectedDbOptions,
+ array $expectedExtraJoinConds
+ ) {
+ $expectedTables = array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables );
+ $expectedFields = array_merge(
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ $expectedExtraFields
+ );
+ $expectedConds = array_merge(
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)', ],
+ $expectedExtraConds
+ );
+ $expectedJoinConds = array_merge(
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ],
+ $expectedExtraJoinConds
+ );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ $expectedTables,
+ $expectedFields,
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions,
+ $expectedJoinConds
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
+
+ $this->assertEmpty( $items );
+ $this->assertNull( $startFrom );
+ }
+
+ public function filterPatrolledOptionProvider() {
+ return [
+ [ WatchedItemQueryService::FILTER_PATROLLED ],
+ [ WatchedItemQueryService::FILTER_NOT_PATROLLED ],
+ ];
+ }
+
+ /**
+ * @dataProvider filterPatrolledOptionProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_filterPatrolledAndUserWithNoPatrolRights(
+ $filtersOption
+ ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ $this->isType( 'array' ),
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ],
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $user = $this->getMockNonAnonUserWithIdAndNoPatrolRights( 1 );
+
+ $queryService = $this->newService( $mockDb );
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'filters' => [ $filtersOption ] ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function mysqlIndexOptimizationProvider() {
+ return [
+ [
+ 'mysql',
+ [],
+ [ "rc_timestamp > ''" ],
+ ],
+ [
+ 'mysql',
+ [ 'start' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ "rc_timestamp <= '20151212010101'" ],
+ ],
+ [
+ 'mysql',
+ [ 'end' => '20151212010101', 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ "rc_timestamp >= '20151212010101'" ],
+ ],
+ [
+ 'postgres',
+ [],
+ [],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider mysqlIndexOptimizationProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_mysqlIndexOptimization(
+ $dbType,
+ array $options,
+ array $expectedExtraConds
+ ) {
+ $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ];
+ $conds = array_merge( $commonConds, $expectedExtraConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ $this->isType( 'array' ),
+ $conds,
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'getType' )
+ ->will( $this->returnValue( $dbType ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function userPermissionRelatedExtraChecksProvider() {
+ return [
+ [
+ [],
+ 'deletedhistory',
+ [],
+ [
+ '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+ LogPage::DELETED_ACTION . ')'
+ ],
+ [],
+ ],
+ [
+ [],
+ 'suppressrevision',
+ [],
+ [
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ [],
+ ],
+ [
+ [],
+ 'viewsuppressed',
+ [],
+ [
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ [],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'deletedhistory',
+ [ 'actormigration' => 'table' ],
+ [
+ 'actormigration_conds',
+ '(rc_deleted & ' . Revision::DELETED_USER . ') != ' . Revision::DELETED_USER,
+ '(rc_type != ' . RC_LOG . ') OR ((rc_deleted & ' . LogPage::DELETED_ACTION . ') != ' .
+ LogPage::DELETED_ACTION . ')'
+ ],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'suppressrevision',
+ [ 'actormigration' => 'table' ],
+ [
+ 'actormigration_conds',
+ '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
+ ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ),
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ [ 'actormigration' => 'join' ],
+ ],
+ [
+ [ 'onlyByUser' => 'SomeOtherUser' ],
+ 'viewsuppressed',
+ [ 'actormigration' => 'table' ],
+ [
+ 'actormigration_conds',
+ '(rc_deleted & ' . ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ) . ') != ' .
+ ( Revision::DELETED_USER | Revision::DELETED_RESTRICTED ),
+ '(rc_type != ' . RC_LOG . ') OR (' .
+ '(rc_deleted & ' . ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ') != ' .
+ ( LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED ) . ')'
+ ],
+ [ 'actormigration' => 'join' ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider userPermissionRelatedExtraChecksProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_userPermissionRelatedExtraChecks(
+ array $options,
+ $notAllowedAction,
+ array $expectedExtraTables,
+ array $expectedExtraConds,
+ array $expectedExtraJoins
+ ) {
+ $commonConds = [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ];
+ $conds = array_merge( $commonConds, $expectedExtraConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ array_merge( [ 'recentchanges', 'watchlist', 'page' ], $expectedExtraTables ),
+ $this->isType( 'array' ),
+ $conds,
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ array_merge( [
+ 'watchlist' => [ 'INNER JOIN', [ 'wl_namespace=rc_namespace', 'wl_title=rc_title' ] ],
+ 'page' => [ 'LEFT JOIN', 'rc_cur_id=page_id' ],
+ ], $expectedExtraJoins )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $user = $this->getMockNonAnonUserWithIdAndRestrictedPermissions( 1, $notAllowedAction );
+
+ $queryService = $this->newService( $mockDb );
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_allRevisionsOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+
+ 'rc_cur_id',
+ 'rc_this_oldid',
+ 'rc_last_oldid',
+ ],
+ [ 'wl_user' => 1, ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo( $user, [ 'allRevisions' => true ] );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider() {
+ return [
+ [
+ [ 'rcTypes' => [ 1337 ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'rcTypes' => [ 'edit' ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'rcTypes' => [ RC_EDIT, 1337 ] ],
+ null,
+ 'Bad value for parameter $options[\'rcTypes\']',
+ ],
+ [
+ [ 'dir' => 'foo' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']',
+ ],
+ [
+ [ 'start' => '20151212010101' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [ 'end' => '20151212010101' ],
+ null,
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [],
+ [ '20151212010101', 123 ],
+ 'Bad value for parameter $options[\'dir\']: must be provided',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ '20151212010101',
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101' ],
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'dir' => WatchedItemQueryService::DIR_OLDER ],
+ [ '20151212010101', 123, 'foo' ],
+ 'Bad value for parameter $startFrom: must be a two-element array',
+ ],
+ [
+ [ 'watchlistOwner' => $this->getMockUnrestrictedNonAnonUserWithId( 2 ) ],
+ null,
+ 'Bad value for parameter $options[\'watchlistOwnerToken\']',
+ ],
+ [
+ [ 'watchlistOwner' => 'Other User', 'watchlistOwnerToken' => 'some-token' ],
+ null,
+ 'Bad value for parameter $options[\'watchlistOwner\']',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsWithRecentChangeInfoInvalidOptionsProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_invalidOptions(
+ array $options,
+ $startFrom,
+ $expectedInExceptionMessage
+ ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
+ $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist', 'page' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_cur_id',
+ ],
+ [ 'wl_user' => 1, '(rc_this_oldid=page_latest) OR (rc_type=3)' ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ 'page' => [
+ 'LEFT JOIN',
+ 'rc_cur_id=page_id',
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'usedInGenerator' => true ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_usedInGeneratorAllRevisionsOptions() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ [ 'recentchanges', 'watchlist' ],
+ [
+ 'rc_id',
+ 'rc_namespace',
+ 'rc_title',
+ 'rc_timestamp',
+ 'rc_type',
+ 'rc_deleted',
+ 'wl_notificationtimestamp',
+ 'rc_this_oldid',
+ ],
+ [ 'wl_user' => 1 ],
+ $this->isType( 'string' ),
+ [],
+ [
+ 'watchlist' => [
+ 'INNER JOIN',
+ [
+ 'wl_namespace=rc_namespace',
+ 'wl_title=rc_title'
+ ]
+ ],
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'usedInGenerator' => true, 'allRevisions' => true, ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerOptionAndEmptyResult() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'array' ),
+ [
+ 'wl_user' => 2,
+ '(rc_this_oldid=page_latest) OR (rc_type=3)',
+ ],
+ $this->isType( 'string' ),
+ $this->isType( 'array' ),
+ $this->isType( 'array' )
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+ $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+ $otherUser->expects( $this->once() )
+ ->method( 'getOption' )
+ ->with( 'watchlisttoken' )
+ ->willReturn( '0123456789abcdef' );
+
+ $items = $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => '0123456789abcdef' ]
+ );
+
+ $this->assertEmpty( $items );
+ }
+
+ public function invalidWatchlistTokenProvider() {
+ return [
+ [ 'wrongToken' ],
+ [ '' ],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidWatchlistTokenProvider
+ */
+ public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockUnrestrictedNonAnonUserWithId( 1 );
+ $otherUser = $this->getMockUnrestrictedNonAnonUserWithId( 2 );
+ $otherUser->expects( $this->once() )
+ ->method( 'getOption' )
+ ->with( 'watchlisttoken' )
+ ->willReturn( '0123456789abcdef' );
+
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+ $queryService->getWatchedItemsWithRecentChangeInfo(
+ $user,
+ [ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ]
+ );
+ }
+
+ public function testGetWatchedItemsForUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Foo1',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Foo2',
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $queryService = $this->newService( $mockDb );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $items = $queryService->getWatchedItemsForUser( $user );
+
+ $this->assertInternalType( 'array', $items );
+ $this->assertCount( 2, $items );
+ $this->assertContainsOnlyInstancesOf( WatchedItem::class, $items );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $items[0]
+ );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $items[1]
+ );
+ }
+
+ public function provideGetWatchedItemsForUserOptions() {
+ return [
+ [
+ [ 'namespaceIds' => [ 0, 1 ], ],
+ [ 'wl_namespace' => [ 0, 1 ], ],
+ []
+ ],
+ [
+ [ 'sort' => WatchedItemQueryService::SORT_ASC, ],
+ [],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0 ],
+ 'sort' => WatchedItemQueryService::SORT_ASC,
+ ],
+ [ 'wl_namespace' => [ 0 ], ],
+ [ 'ORDER BY' => 'wl_title ASC' ]
+ ],
+ [
+ [ 'limit' => 10 ],
+ [],
+ [ 'LIMIT' => 10 ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0, "1; DROP TABLE watchlist;\n--" ],
+ 'limit' => "10; DROP TABLE watchlist;\n--",
+ ],
+ [ 'wl_namespace' => [ 0, 1 ], ],
+ [ 'LIMIT' => 10 ]
+ ],
+ [
+ [ 'filter' => WatchedItemQueryService::FILTER_CHANGED ],
+ [ 'wl_notificationtimestamp IS NOT NULL' ],
+ []
+ ],
+ [
+ [ 'filter' => WatchedItemQueryService::FILTER_NOT_CHANGED ],
+ [ 'wl_notificationtimestamp IS NULL' ],
+ []
+ ],
+ [
+ [ 'sort' => WatchedItemQueryService::SORT_DESC, ],
+ [],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'namespaceIds' => [ 0 ],
+ 'sort' => WatchedItemQueryService::SORT_DESC,
+ ],
+ [ 'wl_namespace' => [ 0 ], ],
+ [ 'ORDER BY' => 'wl_title DESC' ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWatchedItemsForUserOptions
+ */
+ public function testGetWatchedItemsForUser_optionsAndEmptyResult(
+ array $options,
+ array $expectedConds,
+ array $expectedDbOptions
+ ) {
+ $mockDb = $this->getMockDb();
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+
+ $items = $queryService->getWatchedItemsForUser( $user, $options );
+ $this->assertEmpty( $items );
+ }
+
+ public function provideGetWatchedItemsForUser_fromUntilStartFromOptions() {
+ return [
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC,
+ ],
+ [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'until' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'until' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC
+ ],
+ [ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))", ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'AnotherDbKey' ),
+ 'until' => new TitleValue( 0, 'SomeOtherDbKey' ),
+ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_ASC
+ ],
+ [
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))",
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))",
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'SomeDbKey'))",
+ ],
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ ],
+ [
+ [
+ 'from' => new TitleValue( 0, 'SomeOtherDbKey' ),
+ 'until' => new TitleValue( 0, 'AnotherDbKey' ),
+ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ),
+ 'sort' => WatchedItemQueryService::SORT_DESC
+ ],
+ [
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeOtherDbKey'))",
+ "(wl_namespace > 0) OR ((wl_namespace = 0) AND (wl_title >= 'AnotherDbKey'))",
+ "(wl_namespace < 0) OR ((wl_namespace = 0) AND (wl_title <= 'SomeDbKey'))",
+ ],
+ [ 'ORDER BY' => [ 'wl_namespace DESC', 'wl_title DESC' ] ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetWatchedItemsForUser_fromUntilStartFromOptions
+ */
+ public function testGetWatchedItemsForUser_fromUntilStartFromOptions(
+ array $options,
+ array $expectedConds,
+ array $expectedDbOptions
+ ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $expectedConds = array_merge( [ 'wl_user' => 1 ], $expectedConds );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->any() )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return implode( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ $expectedConds,
+ $this->isType( 'string' ),
+ $expectedDbOptions
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $queryService = $this->newService( $mockDb );
+
+ $items = $queryService->getWatchedItemsForUser( $user, $options );
+ $this->assertEmpty( $items );
+ }
+
+ public function getWatchedItemsForUserInvalidOptionsProvider() {
+ return [
+ [
+ [ 'sort' => 'foo' ],
+ 'Bad value for parameter $options[\'sort\']'
+ ],
+ [
+ [ 'filter' => 'foo' ],
+ 'Bad value for parameter $options[\'filter\']'
+ ],
+ [
+ [ 'from' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ [
+ [ 'until' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ [
+ [ 'startFrom' => new TitleValue( 0, 'SomeDbKey' ), ],
+ 'Bad value for parameter $options[\'sort\']: must be provided'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getWatchedItemsForUserInvalidOptionsProvider
+ */
+ public function testGetWatchedItemsForUser_invalidOptionThrowsException(
+ array $options,
+ $expectedInExceptionMessage
+ ) {
+ $queryService = $this->newService( $this->getMockDb() );
+
+ $this->setExpectedException( InvalidArgumentException::class, $expectedInExceptionMessage );
+ $queryService->getWatchedItemsForUser( $this->getMockNonAnonUserWithId( 1 ), $options );
+ }
+
+ public function testGetWatchedItemsForUser_userNotAllowedToViewWatchlist() {
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->never() )
+ ->method( $this->anything() );
+
+ $queryService = $this->newService( $mockDb );
+
+ $items = $queryService->getWatchedItemsForUser( $this->getMockAnonUser() );
+ $this->assertEmpty( $items );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php
new file mode 100644
index 00000000..3102929e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreIntegrationTest.php
@@ -0,0 +1,231 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @author Addshore
+ *
+ * @group Database
+ *
+ * @covers WatchedItemStore
+ */
+class WatchedItemStoreIntegrationTest extends MediaWikiTestCase {
+
+ public function setUp() {
+ parent::setUp();
+ self::$users['WatchedItemStoreIntegrationTestUser']
+ = new TestUser( 'WatchedItemStoreIntegrationTestUser' );
+ }
+
+ private function getUser() {
+ return self::$users['WatchedItemStoreIntegrationTestUser']->getUser();
+ }
+
+ public function testWatchAndUnWatchItem() {
+ $user = $this->getUser();
+ $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ // Cleanup after previous tests
+ $store->removeWatch( $user, $title );
+ $initialWatchers = $store->countWatchers( $title );
+ $initialUserWatchedItems = $store->countWatchedItems( $user );
+
+ $this->assertFalse(
+ $store->isWatched( $user, $title ),
+ 'Page should not initially be watched'
+ );
+
+ $store->addWatch( $user, $title );
+ $this->assertTrue(
+ $store->isWatched( $user, $title ),
+ 'Page should be watched'
+ );
+ $this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
+ $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
+ $this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser );
+ $watchedItemsForUserHasExpectedItem = false;
+ foreach ( $watchedItemsForUser as $watchedItem ) {
+ if (
+ $watchedItem->getUser()->equals( $user ) &&
+ $watchedItem->getLinkTarget() == $title->getTitleValue()
+ ) {
+ $watchedItemsForUserHasExpectedItem = true;
+ }
+ }
+ $this->assertTrue(
+ $watchedItemsForUserHasExpectedItem,
+ 'getWatchedItemsForUser should contain the page'
+ );
+ $this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) );
+ $this->assertEquals(
+ $initialWatchers + 1,
+ $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ],
+ $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] )
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
+ $store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] )
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+
+ $store->removeWatch( $user, $title );
+ $this->assertFalse(
+ $store->isWatched( $user, $title ),
+ 'Page should be unwatched'
+ );
+ $this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );
+ $watchedItemsForUser = $store->getWatchedItemsForUser( $user );
+ $this->assertCount( $initialUserWatchedItems, $watchedItemsForUser );
+ $watchedItemsForUserHasExpectedItem = false;
+ foreach ( $watchedItemsForUser as $watchedItem ) {
+ if (
+ $watchedItem->getUser()->equals( $user ) &&
+ $watchedItem->getLinkTarget() == $title->getTitleValue()
+ ) {
+ $watchedItemsForUserHasExpectedItem = true;
+ }
+ }
+ $this->assertFalse(
+ $watchedItemsForUserHasExpectedItem,
+ 'getWatchedItemsForUser should not contain the page'
+ );
+ $this->assertEquals( $initialWatchers, $store->countWatchers( $title ) );
+ $this->assertEquals(
+ $initialWatchers,
+ $store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => false ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ }
+
+ public function testWatchBatchAndClearItems() {
+ $user = $this->getUser();
+ $title1 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' );
+ $title2 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage2' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $user, [ $title1, $title2 ] );
+
+ $this->assertTrue( $store->isWatched( $user, $title1 ) );
+ $this->assertTrue( $store->isWatched( $user, $title2 ) );
+
+ $store->clearUserWatchedItems( $user );
+
+ $this->assertFalse( $store->isWatched( $user, $title1 ) );
+ $this->assertFalse( $store->isWatched( $user, $title2 ) );
+ }
+
+ public function testUpdateResetAndSetNotificationTimestamp() {
+ $user = $this->getUser();
+ $otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser();
+ $title = Title::newFromText( 'WatchedItemStoreIntegrationTestPage' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->addWatch( $user, $title );
+ $this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() );
+ $initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' );
+ $initialUnreadNotifications = $store->countUnreadNotifications( $user );
+
+ $store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' );
+ $this->assertEquals(
+ '20150202010101',
+ $store->loadWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers - 1,
+ $store->countVisitingWatchers( $title, '20150202020202' )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers - 1,
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ]
+ )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ $initialUnreadNotifications + 1,
+ $store->countUnreadNotifications( $user )
+ );
+ $this->assertSame(
+ true,
+ $store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 )
+ );
+
+ $this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) );
+ $this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() );
+ $this->assertEquals(
+ [ $title->getNamespace() => [ $title->getDBkey() => null ] ],
+ $store->getNotificationTimestampsBatch( $user, [ $title ] )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers,
+ $store->countVisitingWatchers( $title, '20150202020202' )
+ );
+ $this->assertEquals(
+ $initialVisitingWatchers,
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ]
+ )[$title->getNamespace()][$title->getDBkey()]
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ],
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ], $initialVisitingWatchers
+ )
+ );
+ $this->assertEquals(
+ [ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
+ $store->countVisitingWatchersMultiple(
+ [ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1
+ )
+ );
+
+ // setNotificationTimestampsForUser specifying a title
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, '20200202020202', [ $title ] )
+ );
+ $this->assertEquals(
+ '20200202020202',
+ $store->getWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+
+ // setNotificationTimestampsForUser not specifying a title
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, '20210202020202' )
+ );
+ $this->assertEquals(
+ '20210202020202',
+ $store->getWatchedItem( $user, $title )->getNotificationTimestamp()
+ );
+ }
+
+ public function testDuplicateAllAssociatedEntries() {
+ $user = $this->getUser();
+ $titleOld = Title::newFromText( 'WatchedItemStoreIntegrationTestPageOld' );
+ $titleNew = Title::newFromText( 'WatchedItemStoreIntegrationTestPageNew' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $store->addWatch( $user, $titleOld->getSubjectPage() );
+ $store->addWatch( $user, $titleOld->getTalkPage() );
+ // Cleanup after previous tests
+ $store->removeWatch( $user, $titleNew->getSubjectPage() );
+ $store->removeWatch( $user, $titleNew->getTalkPage() );
+
+ $store->duplicateAllAssociatedEntries( $titleOld, $titleNew );
+
+ $this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) );
+ $this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php
new file mode 100644
index 00000000..26f69088
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php
@@ -0,0 +1,2753 @@
+<?php
+use MediaWiki\Linker\LinkTarget;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @author Addshore
+ *
+ * @covers WatchedItemStore
+ */
+class WatchedItemStoreUnitTest extends MediaWikiTestCase {
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|IDatabase
+ */
+ private function getMockDb() {
+ return $this->createMock( IDatabase::class );
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+ */
+ private function getMockLoadBalancer(
+ $mockDb,
+ $expectedConnectionType = null
+ ) {
+ $mock = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ if ( $expectedConnectionType !== null ) {
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->with( $expectedConnectionType )
+ ->will( $this->returnValue( $mockDb ) );
+ } else {
+ $mock->expects( $this->any() )
+ ->method( 'getConnectionRef' )
+ ->will( $this->returnValue( $mockDb ) );
+ }
+ return $mock;
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|HashBagOStuff
+ */
+ private function getMockCache() {
+ $mock = $this->getMockBuilder( HashBagOStuff::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'get', 'set', 'delete', 'makeKey' ] )
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'makeKey' )
+ ->will( $this->returnCallback( function () {
+ return implode( ':', func_get_args() );
+ } ) );
+ return $mock;
+ }
+
+ /**
+ * @return PHPUnit_Framework_MockObject_MockObject|ReadOnlyMode
+ */
+ private function getMockReadOnlyMode( $readOnly = false ) {
+ $mock = $this->getMockBuilder( ReadOnlyMode::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'isReadOnly' )
+ ->will( $this->returnValue( $readOnly ) );
+ return $mock;
+ }
+
+ /**
+ * @param int $id
+ * @return PHPUnit_Framework_MockObject_MockObject|User
+ */
+ private function getMockNonAnonUserWithId( $id ) {
+ $mock = $this->createMock( User::class );
+ $mock->expects( $this->any() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( false ) );
+ $mock->expects( $this->any() )
+ ->method( 'getId' )
+ ->will( $this->returnValue( $id ) );
+ return $mock;
+ }
+
+ /**
+ * @return User
+ */
+ private function getAnonUser() {
+ return User::newFromName( 'Anon_User' );
+ }
+
+ private function getFakeRow( array $rowValues ) {
+ $fakeRow = new stdClass();
+ foreach ( $rowValues as $valueName => $value ) {
+ $fakeRow->$valueName = $value;
+ }
+ return $fakeRow;
+ }
+
+ private function newWatchedItemStore( LoadBalancer $loadBalancer, HashBagOStuff $cache,
+ ReadOnlyMode $readOnlyMode
+ ) {
+ return new WatchedItemStore(
+ $loadBalancer,
+ $cache,
+ $readOnlyMode,
+ 1000
+ );
+ }
+
+ public function testClearWatchedItems() {
+ $user = $this->getMockNonAnonUserWithId( 7 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 12 ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [ 'wl_user' => 7 ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( 'RM-KEY' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+ TestingAccessWrapper::newFromObject( $store )
+ ->cacheIndex = [ 0 => [ 'F' => [ 7 => 'RM-KEY', 9 => 'KEEP-KEY' ] ] ];
+
+ $this->assertTrue( $store->clearUserWatchedItems( $user ) );
+ }
+
+ public function testClearWatchedItems_tooManyItemsWatched() {
+ $user = $this->getMockNonAnonUserWithId( 7 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 99999 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse( $store->clearUserWatchedItems( $user ) );
+ }
+
+ public function testCountWatchedItems() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( '12' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 12, $store->countWatchedItems( $user ) );
+ }
+
+ public function testCountWatchers() {
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $titleValue->getNamespace(),
+ 'wl_title' => $titleValue->getDBkey(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( '7' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 7, $store->countWatchers( $titleValue ) );
+ }
+
+ public function testCountWatchersMultiple() {
+ $titleValues = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 0, 'OtherDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ]
+ ),
+ ];
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+ [ 'makeWhereFrom2d return value' ],
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals( $expected, $store->countWatchersMultiple( $titleValues ) );
+ }
+
+ public function provideIntWithDbUnsafeVersion() {
+ return [
+ [ 50 ],
+ [ "50; DROP TABLE watchlist;\n--" ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountWatchersMultiple_withMinimumWatchers( $minWatchers ) {
+ $titleValues = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 0, 'OtherDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ]
+ ),
+ ];
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1, 'OtherDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
+ [ 'makeWhereFrom2d return value' ],
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ 'HAVING' => 'COUNT(*) >= 50',
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countWatchersMultiple( $titleValues, [ 'minimumWatchers' => $minWatchers ] )
+ );
+ }
+
+ public function testCountVisitingWatchers() {
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_namespace' => $titleValue->getNamespace(),
+ 'wl_title' => $titleValue->getDBkey(),
+ 'wl_notificationtimestamp >= \'TS111TS\' OR wl_notificationtimestamp IS NULL',
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( '7' ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 7, $store->countVisitingWatchers( $titleValue, '111' ) );
+ }
+
+ public function testCountVisitingWatchersMultiple() {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ ];
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ]
+ ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 2 * 3 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 3 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return implode( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->never() )
+ ->method( 'makeWhereFrom2d' );
+
+ $expectedCond =
+ '((wl_namespace = 0) AND (' .
+ "(((wl_title = 'SomeDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')) OR (' .
+ "(wl_title = 'OtherDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ((wl_namespace = 1) AND (' .
+ "(((wl_title = 'AnotherDbKey') AND (".
+ "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')))))';
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $expectedCond,
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 100, 'OtherDbKey' => 300 ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
+ );
+ }
+
+ public function testCountVisitingWatchersMultiple_withMissingTargets() {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ [ new TitleValue( 0, 'SomeNotExisitingDbKey' ), null ],
+ [ new TitleValue( 0, 'OtherNotExisitingDbKey' ), null ],
+ ];
+
+ $dbResult = [
+ $this->getFakeRow( [ 'wl_title' => 'SomeDbKey', 'wl_namespace' => '0', 'watchers' => '100' ] ),
+ $this->getFakeRow( [ 'wl_title' => 'OtherDbKey', 'wl_namespace' => '0', 'watchers' => '300' ] ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'AnotherDbKey', 'wl_namespace' => '1', 'watchers' => '500' ]
+ ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'SomeNotExisitingDbKey', 'wl_namespace' => '0', 'watchers' => '100' ]
+ ),
+ $this->getFakeRow(
+ [ 'wl_title' => 'OtherNotExisitingDbKey', 'wl_namespace' => '0', 'watchers' => '200' ]
+ ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 2 * 3 ) )
+ ->method( 'addQuotes' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return "'$value'";
+ } ) );
+ $mockDb->expects( $this->exactly( 3 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->with(
+ $this->isType( 'array' ),
+ $this->isType( 'int' )
+ )
+ ->will( $this->returnCallback( function ( $a, $conj ) {
+ $sqlConj = $conj === LIST_AND ? ' AND ' : ' OR ';
+ return implode( $sqlConj, array_map( function ( $s ) {
+ return '(' . $s . ')';
+ }, $a
+ ) );
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeNotExisitingDbKey' => 1, 'OtherNotExisitingDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+
+ $expectedCond =
+ '((wl_namespace = 0) AND (' .
+ "(((wl_title = 'SomeDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ ')) OR (' .
+ "(wl_title = 'OtherDbKey') AND (" .
+ "(wl_notificationtimestamp >= 'TS111TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ((wl_namespace = 1) AND (' .
+ "(((wl_title = 'AnotherDbKey') AND (".
+ "(wl_notificationtimestamp >= 'TS123TS') OR (wl_notificationtimestamp IS NULL)" .
+ '))))' .
+ ') OR ' .
+ '(makeWhereFrom2d return value)';
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $expectedCond,
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ ]
+ )
+ ->will(
+ $this->returnValue( $dbResult )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [
+ 'SomeDbKey' => 100, 'OtherDbKey' => 300,
+ 'SomeNotExisitingDbKey' => 100, 'OtherNotExisitingDbKey' => 200
+ ],
+ 1 => [ 'AnotherDbKey' => 500 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds )
+ );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountVisitingWatchersMultiple_withMinimumWatchers( $minWatchers ) {
+ $titleValuesWithThresholds = [
+ [ new TitleValue( 0, 'SomeDbKey' ), '111' ],
+ [ new TitleValue( 0, 'OtherDbKey' ), '111' ],
+ [ new TitleValue( 1, 'AnotherDbKey' ), '123' ],
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->any() )
+ ->method( 'makeList' )
+ ->will( $this->returnValue( 'makeList return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ 'makeList return value',
+ $this->isType( 'string' ),
+ [
+ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ],
+ 'HAVING' => 'COUNT(*) >= 50',
+ ]
+ )
+ ->will(
+ $this->returnValue( [] )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $expected = [
+ 0 => [ 'SomeDbKey' => 0, 'OtherDbKey' => 0 ],
+ 1 => [ 'AnotherDbKey' => 0 ],
+ ];
+ $this->assertEquals(
+ $expected,
+ $store->countVisitingWatchersMultiple( $titleValuesWithThresholds, $minWatchers )
+ );
+ }
+
+ public function testCountUnreadNotifications() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( '9' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals( 9, $store->countUnreadNotifications( $user ) );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountUnreadNotifications_withUnreadLimit_overLimit( $limit ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' ),
+ [ 'LIMIT' => 50 ]
+ )
+ ->will( $this->returnValue( '50' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertSame(
+ true,
+ $store->countUnreadNotifications( $user, $limit )
+ );
+ }
+
+ /**
+ * @dataProvider provideIntWithDbUnsafeVersion
+ */
+ public function testCountUnreadNotifications_withUnreadLimit_underLimit( $limit ) {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'selectRowCount' )
+ ->with(
+ 'watchlist',
+ '1',
+ [
+ "wl_notificationtimestamp IS NOT NULL",
+ 'wl_user' => 1,
+ ],
+ $this->isType( 'string' ),
+ [ 'LIMIT' => 50 ]
+ )
+ ->will( $this->returnValue( '9' ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ 9,
+ $store->countUnreadNotifications( $user, $limit )
+ );
+ }
+
+ public function testDuplicateEntry_nothingToDuplicate() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ],
+ 'WatchedItemStore::duplicateEntry',
+ [ 'FOR UPDATE' ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateEntry(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function testDuplicateEntry_somethingToDuplicate() {
+ $fakeRows = [
+ $this->getFakeRow( [ 'wl_user' => '1', 'wl_notificationtimestamp' => '20151212010101' ] ),
+ $this->getFakeRow( [ 'wl_user' => '2', 'wl_notificationtimestamp' => null ] ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'New_Title',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ [
+ 'wl_user' => 2,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'New_Title',
+ 'wl_notificationtimestamp' => null,
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateEntry(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function testDuplicateAllAssociatedEntries_nothingToDuplicate() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Old_Title',
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( [] ) ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateAllAssociatedEntries(
+ Title::newFromText( 'Old_Title' ),
+ Title::newFromText( 'New_Title' )
+ );
+ }
+
+ public function provideLinkTargetPairs() {
+ return [
+ [ Title::newFromText( 'Old_Title' ), Title::newFromText( 'New_Title' ) ],
+ [ new TitleValue( 0, 'Old_Title' ), new TitleValue( 0, 'New_Title' ) ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideLinkTargetPairs
+ */
+ public function testDuplicateAllAssociatedEntries_somethingToDuplicate(
+ LinkTarget $oldTarget,
+ LinkTarget $newTarget
+ ) {
+ $fakeRows = [
+ $this->getFakeRow( [ 'wl_user' => '1', 'wl_notificationtimestamp' => '20151212010101' ] ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->at( 0 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => $oldTarget->getNamespace(),
+ 'wl_title' => $oldTarget->getDBkey(),
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 1 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => $newTarget->getNamespace(),
+ 'wl_title' => $newTarget->getDBkey(),
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+ $mockDb->expects( $this->at( 2 ) )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user',
+ 'wl_notificationtimestamp',
+ ],
+ [
+ 'wl_namespace' => $oldTarget->getNamespace() + 1,
+ 'wl_title' => $oldTarget->getDBkey(),
+ ]
+ )
+ ->will( $this->returnValue( new FakeResultWrapper( $fakeRows ) ) );
+ $mockDb->expects( $this->at( 3 ) )
+ ->method( 'replace' )
+ ->with(
+ 'watchlist',
+ [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => $newTarget->getNamespace() + 1,
+ 'wl_title' => $newTarget->getDBkey(),
+ 'wl_notificationtimestamp' => '20151212010101',
+ ],
+ ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->duplicateAllAssociatedEntries(
+ $oldTarget,
+ $newTarget
+ );
+ }
+
+ public function testAddWatch_nonAnonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'insert' )
+ ->with(
+ 'watchlist',
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:Some_Page:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->addWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ Title::newFromText( 'Some_Page' )
+ );
+ }
+
+ public function testAddWatch_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $store->addWatch(
+ $this->getAnonUser(),
+ Title::newFromText( 'Some_Page' )
+ );
+ }
+
+ public function testAddWatchBatchForUser_readOnlyDBReturnsFalse() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode( true )
+ );
+
+ $this->assertFalse(
+ $store->addWatchBatchForUser(
+ $this->getMockNonAnonUserWithId( 1 ),
+ [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchForUser_nonAnonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'insert' )
+ ->with(
+ 'watchlist',
+ [
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ],
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Some_Page',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->exactly( 2 ) )
+ ->method( 'delete' );
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'delete' )
+ ->with( '0:Some_Page:1' );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'delete' )
+ ->with( '1:Some_Page:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $mockUser = $this->getMockNonAnonUserWithId( 1 );
+
+ $this->assertTrue(
+ $store->addWatchBatchForUser(
+ $mockUser,
+ [ new TitleValue( 0, 'Some_Page' ), new TitleValue( 1, 'Some_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchForUser_anonymousUsersAreSkipped() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->addWatchBatchForUser(
+ $this->getAnonUser(),
+ [ new TitleValue( 0, 'Other_Page' ) ]
+ )
+ );
+ }
+
+ public function testAddWatchBatchReturnsTrue_whenGivenEmptyList() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'insert' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->addWatchBatchForUser( $user, [] )
+ );
+ }
+
+ public function testLoadWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItem = $store->loadWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ );
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertEquals( 1, $watchedItem->getUser()->getId() );
+ $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() );
+ $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() );
+ }
+
+ public function testLoadWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->loadWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testLoadWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->loadWatchedItem(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'affectedRows' )
+ ->will( $this->returnValue( 1 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->removeWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'affectedRows' )
+ ->will( $this->returnValue( 0 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->removeWatch(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testRemoveWatch_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'delete' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )
+ ->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->removeWatch(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with(
+ '0:SomeDbKey:1'
+ )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItem = $store->getWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ );
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ $this->assertEquals( 1, $watchedItem->getUser()->getId() );
+ $this->assertEquals( 'SomeDbKey', $watchedItem->getLinkTarget()->getDBkey() );
+ $this->assertEquals( 0, $watchedItem->getLinkTarget()->getNamespace() );
+ }
+
+ public function testGetWatchedItem_cachedItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockUser = $this->getMockNonAnonUserWithId( 1 );
+ $linkTarget = new TitleValue( 0, 'SomeDbKey' );
+ $cachedItem = new WatchedItem( $mockUser, $linkTarget, '20151212010101' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with(
+ '0:SomeDbKey:1'
+ )
+ ->will( $this->returnValue( $cachedItem ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ $cachedItem,
+ $store->getWatchedItem(
+ $mockUser,
+ $linkTarget
+ )
+ );
+ }
+
+ public function testGetWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->getWatchedItem(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->getWatchedItem(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetWatchedItemsForUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow( [
+ 'wl_namespace' => 0,
+ 'wl_title' => 'Foo1',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow( [
+ 'wl_namespace' => 1,
+ 'wl_title' => 'Foo2',
+ 'wl_notificationtimestamp' => null,
+ ] ),
+ ] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $watchedItems = $store->getWatchedItemsForUser( $user );
+
+ $this->assertInternalType( 'array', $watchedItems );
+ $this->assertCount( 2, $watchedItems );
+ foreach ( $watchedItems as $watchedItem ) {
+ $this->assertInstanceOf( WatchedItem::class, $watchedItem );
+ }
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 0, 'Foo1' ), '20151212010101' ),
+ $watchedItems[0]
+ );
+ $this->assertEquals(
+ new WatchedItem( $user, new TitleValue( 1, 'Foo2' ), null ),
+ $watchedItems[1]
+ );
+ }
+
+ public function provideDbTypes() {
+ return [
+ [ false, DB_REPLICA ],
+ [ true, DB_MASTER ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDbTypes
+ */
+ public function testGetWatchedItemsForUser_optionsAndEmptyResult( $forWrite, $dbType ) {
+ $mockDb = $this->getMockDb();
+ $mockCache = $this->getMockCache();
+ $mockLoadBalancer = $this->getMockLoadBalancer( $mockDb, $dbType );
+ $user = $this->getMockNonAnonUserWithId( 1 );
+
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => 1 ],
+ $this->isType( 'string' ),
+ [ 'ORDER BY' => [ 'wl_namespace ASC', 'wl_title ASC' ] ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $store = $this->newWatchedItemStore(
+ $mockLoadBalancer,
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchedItems = $store->getWatchedItemsForUser(
+ $user,
+ [ 'forWrite' => $forWrite, 'sort' => WatchedItemStore::SORT_ASC ]
+ );
+ $this->assertEquals( [], $watchedItems );
+ }
+
+ public function testGetWatchedItemsForUser_badSortOptionThrowsException() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->setExpectedException( InvalidArgumentException::class );
+ $store->getWatchedItemsForUser(
+ $this->getMockNonAnonUserWithId( 1 ),
+ [ 'sort' => 'foo' ]
+ );
+ }
+
+ public function testIsWatchedItem_existingItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1'
+ );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->isWatched(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testIsWatchedItem_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( false ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->isWatched(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testIsWatchedItem_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->isWatched(
+ $this->getAnonUser(),
+ new TitleValue( 0, 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $dbResult = [
+ $this->getFakeRow( [
+ 'wl_namespace' => '0',
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp' => '20151212010101',
+ ] ),
+ $this->getFakeRow(
+ [
+ 'wl_namespace' => '1',
+ 'wl_title' => 'AnotherDbKey',
+ 'wl_notificationtimestamp' => null,
+ ]
+ ),
+ ];
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'SomeDbKey' => 1 ], [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( $dbResult ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->exactly( 2 ) )
+ ->method( 'get' )
+ ->withConsecutive(
+ [ '0:SomeDbKey:1' ],
+ [ '1:AnotherDbKey:1' ]
+ )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_notWatchedTarget() {
+ $targets = [
+ new TitleValue( 0, 'OtherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'OtherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( $this->getFakeRow( [] ) ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:OtherDbKey:1' )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'OtherDbKey' => false, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getMockNonAnonUserWithId( 1 ), $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_cachedItem() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $cachedItem = new WatchedItem( $user, $targets[0], '20151212010101' );
+
+ $mockDb = $this->getMockDb();
+
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ 1 => [ 'AnotherDbKey' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'select' )
+ ->with(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ 'makeWhereFrom2d return value',
+ 'wl_user' => 1
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( [
+ $this->getFakeRow(
+ [ 'wl_namespace' => '1', 'wl_title' => 'AnotherDbKey', 'wl_notificationtimestamp' => null, ]
+ )
+ ] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( $cachedItem ) );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'get' )
+ ->with( '1:AnotherDbKey:1' )
+ ->will( $this->returnValue( null ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $user, $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_allItemsCached() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $cachedItems = [
+ new WatchedItem( $user, $targets[0], '20151212010101' ),
+ new WatchedItem( $user, $targets[1], null ),
+ ];
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )->method( $this->anything() );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->at( 1 ) )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' )
+ ->will( $this->returnValue( $cachedItems[0] ) );
+ $mockCache->expects( $this->at( 3 ) )
+ ->method( 'get' )
+ ->with( '1:AnotherDbKey:1' )
+ ->will( $this->returnValue( $cachedItems[1] ) );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => '20151212010101', ],
+ 1 => [ 'AnotherDbKey' => null, ],
+ ],
+ $store->getNotificationTimestampsBatch( $user, $targets )
+ );
+ }
+
+ public function testGetNotificationTimestampsBatch_anonymousUser() {
+ $targets = [
+ new TitleValue( 0, 'SomeDbKey' ),
+ new TitleValue( 1, 'AnotherDbKey' ),
+ ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )->method( $this->anything() );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( $this->anything() );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [
+ 0 => [ 'SomeDbKey' => false, ],
+ 1 => [ 'AnotherDbKey' => false, ],
+ ],
+ $store->getNotificationTimestampsBatch( $this->getAnonUser(), $targets )
+ );
+ }
+
+ public function testResetNotificationTimestamp_anonymousUser() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->resetNotificationTimestamp(
+ $this->getAnonUser(),
+ Title::newFromText( 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testResetNotificationTimestamp_noItem() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( [] ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse(
+ $store->resetNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ Title::newFromText( 'SomeDbKey' )
+ )
+ );
+ }
+
+ public function testResetNotificationTimestamp_item() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $title = Title::newFromText( 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with(
+ '0:SomeDbKey:1',
+ $this->isInstanceOf( WatchedItem::class )
+ );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // Note: This does not actually assert the job is correct
+ $callableCallCounter = 0;
+ $mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
+ $callableCallCounter++;
+ $this->assertInternalType( 'callable', $callable );
+ };
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_noItemForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $title = Title::newFromText( 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // Note: This does not actually assert the job is correct
+ $callableCallCounter = 0;
+ $mockCallback = function ( $callable ) use ( &$callableCallCounter ) {
+ $callableCallCounter++;
+ $this->assertInternalType( 'callable', $callable );
+ };
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force'
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ /**
+ * @param string $text
+ * @param int $ns
+ *
+ * @return PHPUnit_Framework_MockObject_MockObject|Title
+ */
+ private function getMockTitle( $text, $ns = 0 ) {
+ $title = $this->createMock( Title::class );
+ $title->expects( $this->any() )
+ ->method( 'getText' )
+ ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) );
+ $title->expects( $this->any() )
+ ->method( 'getDbKey' )
+ ->will( $this->returnValue( str_replace( '_', ' ', $text ) ) );
+ $title->expects( $this->any() )
+ ->method( 'getNamespace' )
+ ->will( $this->returnValue( $ns ) );
+ return $title;
+ }
+
+ private function verifyCallbackJob(
+ $callback,
+ LinkTarget $expectedTitle,
+ $expectedUserId,
+ callable $notificationTimestampCondition
+ ) {
+ $this->assertInternalType( 'callable', $callback );
+
+ $callbackReflector = new ReflectionFunction( $callback );
+ $vars = $callbackReflector->getStaticVariables();
+ $this->assertArrayHasKey( 'job', $vars );
+ $this->assertInstanceOf( ActivityUpdateJob::class, $vars['job'] );
+
+ /** @var ActivityUpdateJob $job */
+ $job = $vars['job'];
+ $this->assertEquals( $expectedTitle->getDBkey(), $job->getTitle()->getDBkey() );
+ $this->assertEquals( $expectedTitle->getNamespace(), $job->getTitle()->getNamespace() );
+
+ $jobParams = $job->getParams();
+ $this->assertArrayHasKey( 'type', $jobParams );
+ $this->assertEquals( 'updateWatchlistNotification', $jobParams['type'] );
+ $this->assertArrayHasKey( 'userid', $jobParams );
+ $this->assertEquals( $expectedUserId, $jobParams['userid'] );
+ $this->assertArrayHasKey( 'notifTime', $jobParams );
+ $this->assertTrue( $notificationTimestampCondition( $jobParams['notifTime'] ) );
+ }
+
+ public function testResetNotificationTimestamp_oldidSpecifiedLatestRevisionForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeTitle' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( false ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->never() )
+ ->method( 'selectRow' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeTitle:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $callableCallCounter = 0;
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$callableCallCounter, $title, $user ) {
+ $callableCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === null;
+ }
+ );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_oldidSpecifiedNotLatestRevisionForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time !== null && $time > '20151212010101';
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testResetNotificationTimestamp_notWatchedPageForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue( false ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $callableCallCounter = 0;
+ $scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$callableCallCounter, $title, $user ) {
+ $callableCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === null;
+ }
+ );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $callableCallCounter );
+
+ ScopedCallback::consume( $scopedOverride );
+ }
+
+ public function testResetNotificationTimestamp_futureNotificationTimestampForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === '30151212010101';
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ 'force',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testResetNotificationTimestamp_futureNotificationTimestampNotForced() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $oldid = 22;
+ $title = $this->getMockTitle( 'SomeDbKey' );
+ $title->expects( $this->once() )
+ ->method( 'getNextRevisionID' )
+ ->with( $oldid )
+ ->will( $this->returnValue( 33 ) );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->with(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [
+ 'wl_user' => 1,
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '30151212010101' ] )
+ ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $addUpdateCallCounter = 0;
+ $scopedOverrideDeferred = $store->overrideDeferredUpdatesAddCallableUpdateCallback(
+ function ( $callable ) use ( &$addUpdateCallCounter, $title, $user ) {
+ $addUpdateCallCounter++;
+ $this->verifyCallbackJob(
+ $callable,
+ $title,
+ $user->getId(),
+ function ( $time ) {
+ return $time === false;
+ }
+ );
+ }
+ );
+
+ $getTimestampCallCounter = 0;
+ $scopedOverrideRevision = $store->overrideRevisionGetTimestampFromIdCallback(
+ function ( $titleParam, $oldidParam ) use ( &$getTimestampCallCounter, $title, $oldid ) {
+ $getTimestampCallCounter++;
+ $this->assertEquals( $title, $titleParam );
+ $this->assertEquals( $oldid, $oldidParam );
+ }
+ );
+
+ $this->assertTrue(
+ $store->resetNotificationTimestamp(
+ $user,
+ $title,
+ '',
+ $oldid
+ )
+ );
+ $this->assertEquals( 1, $addUpdateCallCounter );
+ $this->assertEquals( 1, $getTimestampCallCounter );
+
+ ScopedCallback::consume( $scopedOverrideDeferred );
+ ScopedCallback::consume( $scopedOverrideRevision );
+ }
+
+ public function testSetNotificationTimestampsForUser_anonUser() {
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $this->getMockDb() ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+ $this->assertFalse( $store->setNotificationTimestampsForUser( $this->getAnonUser(), '' ) );
+ }
+
+ public function testSetNotificationTimestampsForUser_allRows() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = '20100101010101';
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp )
+ );
+ }
+
+ public function testSetNotificationTimestampsForUser_nullTimestamp() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = null;
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [ 'wl_user' => 1 ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 0 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp )
+ );
+ }
+
+ public function testSetNotificationTimestampsForUser_specificTargets() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $timestamp = '20100101010101';
+ $targets = [ new TitleValue( 0, 'Foo' ), new TitleValue( 0, 'Bar' ) ];
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => 'TS' . $timestamp . 'TS' ],
+ [ 'wl_user' => 1, 0 => 'makeWhereFrom2d return value' ]
+ )
+ ->will( $this->returnValue( true ) );
+ $mockDb->expects( $this->exactly( 1 ) )
+ ->method( 'timestamp' )
+ ->will( $this->returnCallback( function ( $value ) {
+ return 'TS' . $value . 'TS';
+ } ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'makeWhereFrom2d' )
+ ->with(
+ [ [ 'Foo' => 1, 'Bar' => 1 ] ],
+ $this->isType( 'string' ),
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 'makeWhereFrom2d return value' ) );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $this->getMockCache(),
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertTrue(
+ $store->setNotificationTimestampsForUser( $user, $timestamp, $targets )
+ );
+ }
+
+ public function testUpdateNotificationTimestamp_watchersExist() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->with(
+ 'watchlist',
+ 'wl_user',
+ [
+ 'wl_user != 1',
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp IS NULL'
+ ]
+ )
+ ->will( $this->returnValue( [ '2', '3' ] ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'update' )
+ ->with(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [
+ 'wl_user' => [ 2, 3 ],
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ ]
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertEquals(
+ [ 2, 3 ],
+ $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' ),
+ '20151212010101'
+ )
+ );
+ }
+
+ public function testUpdateNotificationTimestamp_noWatchers() {
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->with(
+ 'watchlist',
+ 'wl_user',
+ [
+ 'wl_user != 1',
+ 'wl_namespace' => 0,
+ 'wl_title' => 'SomeDbKey',
+ 'wl_notificationtimestamp IS NULL'
+ ]
+ )
+ ->will(
+ $this->returnValue( [] )
+ );
+ $mockDb->expects( $this->never() )
+ ->method( 'update' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $watchers = $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ new TitleValue( 0, 'SomeDbKey' ),
+ '20151212010101'
+ );
+ $this->assertInternalType( 'array', $watchers );
+ $this->assertEmpty( $watchers );
+ }
+
+ public function testUpdateNotificationTimestamp_clearsCachedItems() {
+ $user = $this->getMockNonAnonUserWithId( 1 );
+ $titleValue = new TitleValue( 0, 'SomeDbKey' );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectRow' )
+ ->will( $this->returnValue(
+ $this->getFakeRow( [ 'wl_notificationtimestamp' => '20151212010101' ] )
+ ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'selectFieldValues' )
+ ->will(
+ $this->returnValue( [ '2', '3' ] )
+ );
+ $mockDb->expects( $this->once() )
+ ->method( 'update' );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->once() )
+ ->method( 'set' )
+ ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+ $mockCache->expects( $this->once() )
+ ->method( 'get' )
+ ->with( '0:SomeDbKey:1' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( '0:SomeDbKey:1' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ // This will add the item to the cache
+ $store->getWatchedItem( $user, $titleValue );
+
+ $store->updateNotificationTimestamp(
+ $this->getMockNonAnonUserWithId( 1 ),
+ $titleValue,
+ '20151212010101'
+ );
+ }
+
+}