From 46df64d2084faad2b6f0c7af3ec19d0aff08a68e Mon Sep 17 00:00:00 2001 From: Rohit Verma <101377978+rohit9625@users.noreply.github.com> Date: Mon, 26 Aug 2024 12:13:50 +0530 Subject: [PATCH] Prevent deletion of other structured data when editing depicts (#5741) * restructure : minor changes to comments to improve readability * api: remove clear flag to prevent deletion of structured data * WikiBaseInterface: add new api methods Get Method: to get claims for an entity Post method: to delete claims * WikiBaseClient: add methods to handle response for new APIs * typo: update call to method with updated typo * DepictEditHelper: call update property method with entity id * refactor: dismiss progress dialog on error * DepictsDao: remove usage of runBlocking as it was blocking main thread Refactor methods to perform well with coroutines * refactor: update usage of method to match changes in DepictsDao * refactor: use named parameters to improve readability * claims: add new data classes to represent remove claims * WikidataEditService: modify update depicts property method Performs deletion of old claims and creation of new claims * refactor: make methods more organized --- .../nrw/commons/upload/WikiBaseInterface.kt | 18 ++- .../free/nrw/commons/upload/depicts/Claims.kt | 9 ++ .../upload/depicts/DepictEditHelper.kt | 2 +- .../nrw/commons/upload/depicts/DepictsDao.kt | 119 ++++++++---------- .../upload/depicts/DepictsPresenter.kt | 19 +-- .../nrw/commons/wikidata/WikiBaseClient.kt | 20 ++- .../commons/wikidata/WikidataEditService.java | 71 ++++++----- .../nrw/commons/wikidata/model/EditClaim.kt | 18 +-- .../nrw/commons/wikidata/model/RemoveClaim.kt | 20 +++ .../commons/wikidata/model/Snak_partial.kt | 12 +- .../wikidata/model/Statement_partial.kt | 2 +- .../wikidata/WikiBaseClientUnitTest.kt | 2 +- 12 files changed, 188 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/depicts/Claims.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/model/RemoveClaim.kt diff --git a/app/src/main/java/fr/free/nrw/commons/upload/WikiBaseInterface.kt b/app/src/main/java/fr/free/nrw/commons/upload/WikiBaseInterface.kt index 62cf4bfbd..1bc5f84fc 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/WikiBaseInterface.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/WikiBaseInterface.kt @@ -1,5 +1,6 @@ package fr.free.nrw.commons.upload +import fr.free.nrw.commons.upload.depicts.Claims import fr.free.nrw.commons.wikidata.WikidataConstants import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse @@ -34,7 +35,7 @@ interface WikiBaseInterface { */ @Headers("Cache-Control: no-cache") @FormUrlEncoded - @POST(WikidataConstants.MW_API_PREFIX + "action=wbeditentity&site=commonswiki&clear=1") + @POST(WikidataConstants.MW_API_PREFIX + "action=wbeditentity&site=commonswiki") fun postEditEntityByFilename( @Field("title") filename: String, @Field("token") editToken: String, @@ -59,4 +60,19 @@ interface WikiBaseInterface { @Field("language") language: String?, @Field("value") captionValue: String? ): Observable + + @GET(WikidataConstants.MW_API_PREFIX + "action=wbgetclaims") + fun getClaimsByProperty( + @Query("entity") entityId: String, + @Query("property") property: String + ) : Observable + + @Headers("Cache-Control: no-cache") + @FormUrlEncoded + @POST(WikidataConstants.MW_API_PREFIX + "action=wbeditentity") + fun postDeleteClaims( + @Field("token") editToken: String, + @Field("id") entityId: String, + @Field("data") data: String + ): Observable } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/Claims.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/Claims.kt new file mode 100644 index 000000000..e0a3a8e8b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/Claims.kt @@ -0,0 +1,9 @@ +package fr.free.nrw.commons.upload.depicts + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.model.Statement_partial + +data class Claims( + @SerializedName(value = "claims") + val claims: Map> = emptyMap() +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictEditHelper.kt index 92cd29cbf..c681e5b7d 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictEditHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictEditHelper.kt @@ -69,7 +69,7 @@ class DepictEditHelper @Inject constructor (notificationHelper: NotificationHelp */ private fun addDepiction(media: Media, depictions: List): Observable { Timber.d("thread is adding depiction %s", Thread.currentThread().name) - return wikidataEditService.updateDepictsProperty(media.filename, depictions) + return wikidataEditService.updateDepictsProperty(media.pageId, depictions) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsDao.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsDao.kt index 9b9d31ed2..d79240601 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsDao.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsDao.kt @@ -1,112 +1,99 @@ package fr.free.nrw.commons.upload.depicts -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query import fr.free.nrw.commons.upload.structure.depictions.DepictedItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.* +import java.util.Date /** * Dao class for DepictsRoomDataBase */ @Dao abstract class DepictsDao { - - /** - * insert Depicts in DepictsRoomDataBase - */ + /** The maximum number of depicts allowed in the database. */ + private val maxItemsAllowed = 10 + @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun insert(depictedItem: Depicts) - - /** - * get all Depicts from roomdatabase - */ + @Query("Select * From depicts_table order by lastUsed DESC") - abstract suspend fun getAllDepict(): List + abstract suspend fun getAllDepicts(): List - /** - * get all Depicts which need to delete from roomdatabase - */ @Query("Select * From depicts_table order by lastUsed DESC LIMIT :n OFFSET 10") - abstract suspend fun getItemToDelete(n: Int): List + abstract suspend fun getDepictsForDeletion(n: Int): List - /** - * Delete Depicts from roomdatabase - */ @Delete abstract suspend fun delete(depicts: Depicts) - lateinit var allDepict: List - lateinit var listOfDelete: List - /** - * get all depicts from DepictsRoomDatabase + * Gets all Depicts objects from the database, ordered by lastUsed in descending order. + * + * @return A list of Depicts objects. */ - fun depictsList(): List { - runBlocking { - launch(Dispatchers.IO) { - allDepict = getAllDepict() - } - } - return allDepict + fun depictsList(): Deferred> = CoroutineScope(Dispatchers.IO).async { + getAllDepicts() } /** - * insert Depicts in DepictsRoomDataBase + * Inserts a Depicts object into the database. + * + * @param depictedItem The Depicts object to insert. */ - fun insertDepict(depictes: Depicts) { - runBlocking { - launch(Dispatchers.IO) { - insert(depictes) - } - } + private fun insertDepict(depictedItem: Depicts) = CoroutineScope(Dispatchers.IO).launch { + insert(depictedItem) } /** - * get all Depicts item which need to delete + * Gets a list of Depicts objects that need to be deleted from the database. + * + * @param n The number of depicts to delete. + * @return A list of Depicts objects to delete. */ - fun getItemTodelete(number: Int): List { - runBlocking { - launch(Dispatchers.IO) { - listOfDelete = getItemToDelete(number) - } - } - return listOfDelete + private suspend fun depictsForDeletion(n: Int): Deferred> = CoroutineScope(Dispatchers.IO).async { + getDepictsForDeletion(n) } /** - * delete Depicts in DepictsRoomDataBase + * Deletes a Depicts object from the database. + * + * @param depicts The Depicts object to delete. */ - fun deleteDepicts(depictes: Depicts) { - runBlocking { - launch(Dispatchers.IO) { - delete(depictes) - } - } + private suspend fun deleteDepicts(depicts: Depicts) = CoroutineScope(Dispatchers.IO).launch { + delete(depicts) } /** - * save Depicts in DepictsRoomDataBase + * Saves a list of DepictedItems in the DepictsRoomDataBase. */ fun savingDepictsInRoomDataBase(listDepictedItem: List) { - var numberofItemInRoomDataBase: Int - val maxNumberOfItemSaveInRoom = 10 + CoroutineScope(Dispatchers.IO).launch { + for (depictsItem in listDepictedItem) { + depictsItem.isSelected = false + insertDepict(Depicts(depictsItem, Date())) + } - for (depictsItem in listDepictedItem) { - depictsItem.isSelected = false - insertDepict(Depicts(depictsItem, Date())) + // Deletes old Depicts objects from the database if + // the number of depicts exceeds the maximum allowed. + deleteOldDepictions(depictsList().await().size) } + } - numberofItemInRoomDataBase = depictsList().size - // delete the depictItem from depictsroomdataBase when number of element in depictsroomdataBase is greater than 10 - if (numberofItemInRoomDataBase > maxNumberOfItemSaveInRoom) { + private suspend fun deleteOldDepictions(depictsListSize: Int) { + if(depictsListSize > maxItemsAllowed) { + val depictsForDeletion = depictsForDeletion(depictsListSize).await() - val listOfDepictsToDelete: List = - getItemTodelete(numberofItemInRoomDataBase) - for (i in listOfDepictsToDelete) { - deleteDepicts(i) + for(depicts in depictsForDeletion) { + deleteDepicts(depicts) } } } -} \ No newline at end of file +} + diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt index edcda24f0..cd0033e08 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt @@ -16,6 +16,9 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import io.reactivex.processors.PublishProcessor import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber import java.lang.reflect.Proxy import java.util.* @@ -223,9 +226,8 @@ class DepictsPresenter @Inject constructor( if (error is InvalidLoginTokenException) { view.navigateToLoginScreen(); } else { - Timber.e( - "Failed to update depictions" - ) + view.dismissProgressDialog() + Timber.e("Failed to update depictions") } }) ) @@ -268,10 +270,13 @@ class DepictsPresenter @Inject constructor( */ fun getRecentDepictedItems(): MutableList { val depictedItemList: MutableList = ArrayList() - val depictsList = depictsDao.depictsList() - for (i in depictsList.indices) { - val depictedItem = depictsList[i].item - depictedItemList.add(depictedItem) + CoroutineScope(Dispatchers.IO).launch { + val depictsList = depictsDao.depictsList().await() + + for (i in depictsList.indices) { + val depictedItem = depictsList[i].item + depictedItemList.add(depictedItem) + } } return depictedItemList } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.kt index f6f8b9ade..70a976164 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.kt @@ -8,7 +8,6 @@ import fr.free.nrw.commons.upload.WikiBaseInterface import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse import io.reactivex.Observable -import timber.log.Timber import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @@ -42,12 +41,29 @@ class WikiBaseClient @Inject constructor( } } + fun getClaimIdsByProperty(fileEntityId: String, property: String ): Observable> { + return wikiBaseInterface.getClaimsByProperty(fileEntityId, property).map { claimsResponse -> + claimsResponse.claims[property]?.mapNotNull { claim -> claim.id } ?: emptyList() + } + } + + fun postDeleteClaims(entityId: String, data: String): Observable { + return csrfToken().switchMap { editToken -> + wikiBaseInterface.postDeleteClaims(editToken, entityId, data) + .map { response: MwPostResponse -> response.successVal == 1 } + } + } + fun getFileEntityId(uploadResult: UploadResult): Observable { return wikiBaseInterface.getFileEntityId(uploadResult.createCanonicalFileName()) .map { response: MwQueryResponse -> response.query()!!.pages()!![0].pageId().toLong() } } - fun addLabelstoWikidata(fileEntityId: Long, languageCode: String?, captionValue: String?): Observable { + fun addLabelsToWikidata( + fileEntityId: Long, + languageCode: String?, + captionValue: String? + ): Observable { return csrfToken().switchMap { editToken -> wikiBaseInterface.addLabelstoWikidata( PAGE_ID_PREFIX + fileEntityId, diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java index ca2093e26..8e298cc9a 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java @@ -8,7 +8,6 @@ import android.content.Context; import androidx.annotation.Nullable; import com.google.gson.Gson; import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException; import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.upload.UploadResult; @@ -19,9 +18,11 @@ import fr.free.nrw.commons.utils.ViewUtil; import fr.free.nrw.commons.wikidata.model.DataValue; import fr.free.nrw.commons.wikidata.model.DataValue.ValueString; import fr.free.nrw.commons.wikidata.model.EditClaim; +import fr.free.nrw.commons.wikidata.model.RemoveClaim; import fr.free.nrw.commons.wikidata.model.Snak_partial; import fr.free.nrw.commons.wikidata.model.Statement_partial; import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue; +import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse; import io.reactivex.Observable; import io.reactivex.schedulers.Schedulers; import java.util.ArrayList; @@ -34,7 +35,6 @@ import java.util.UUID; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; -import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse; import timber.log.Timber; /** @@ -72,9 +72,10 @@ public class WikidataEditService { * to the wikibase API to set tag against the entity. */ @SuppressLint("CheckResult") - private Observable addDepictsProperty(final String fileEntityId, - final List depictedItems) { - + private Observable addDepictsProperty( + final String fileEntityId, + final List depictedItems + ) { final EditClaim data = editClaim( ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") // Wikipedia:Sandbox (Q10) @@ -100,45 +101,54 @@ public class WikidataEditService { * Takes depicts ID as a parameter and create a uploadable data with the Id * and send the data for POST operation * - * @param filename name of the file - * @param depictedItems ID of the selected depict item + * @param fileEntityId ID of the file + * @param depictedItems IDs of the selected depict item * @return Observable */ @SuppressLint("CheckResult") - public Observable updateDepictsProperty(final String filename, - final List depictedItems) { + public Observable updateDepictsProperty( + final String fileEntityId, + final List depictedItems + ) { + final String entityId = PAGE_ID_PREFIX + fileEntityId; + final List claimIds = getDepictionsClaimIds(entityId); - final EditClaim data = editClaim( + final RemoveClaim data = removeClaim( /* Please consider removeClaim scenario for BetaDebug */ ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") // Wikipedia:Sandbox (Q10) - : depictedItems + : claimIds ); - return wikiBaseClient.postEditEntityByFilename(filename, - gson.toJson(data)) - .doOnNext(success -> { - if (success) { - Timber.d("DEPICTS property was set successfully for %s", filename); - } else { - Timber.d("Unable to set DEPICTS property for %s", filename); - } - }) + return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) .doOnError(throwable -> { - if (throwable instanceof InvalidLoginTokenException) { - Observable.error(throwable); + Timber.e(throwable, "Error occurred while removing existing claims for DEPICTS property"); + ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); + }).switchMap(success-> { + if(success) { + Timber.d("DEPICTS property was deleted successfully"); + return addDepictsProperty(fileEntityId, depictedItems); } else { - Timber.e(throwable, "Error occurred while setting DEPICTS property"); - ViewUtil.showLongToast(context, throwable.toString()); + Timber.d("Unable to delete DEPICTS property"); + return Observable.empty(); } + }); + } - }) - .subscribeOn(Schedulers.io()); + @SuppressLint("CheckResult") + private List getDepictionsClaimIds(final String entityId) { + return wikiBaseClient.getClaimIdsByProperty(entityId, WikidataProperties.DEPICTS.getPropertyName()) + .subscribeOn(Schedulers.io()) + .blockingFirst(); } private EditClaim editClaim(final List entityIds) { return EditClaim.from(entityIds, WikidataProperties.DEPICTS.getPropertyName()); } + private RemoveClaim removeClaim(final List claimIds) { + return RemoveClaim.from(claimIds); + } + /** * Show a success toast when the edit is made successfully */ @@ -156,11 +166,10 @@ public class WikidataEditService { * @param fileEntityId * @return */ - @SuppressLint("CheckResult") private Observable addCaption(final long fileEntityId, final String languageCode, final String captionValue) { - return wikiBaseClient.addLabelstoWikidata(fileEntityId, languageCode, captionValue) + return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) .doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse)) .doOnError(throwable -> { Timber.e(throwable, "Error occurred while setting Captions"); @@ -220,8 +229,10 @@ public class WikidataEditService { } } - public Observable addDepictionsAndCaptions(final UploadResult uploadResult, - final Contribution contribution) { + public Observable addDepictionsAndCaptions( + final UploadResult uploadResult, + final Contribution contribution + ) { return wikiBaseClient.getFileEntityId(uploadResult) .doOnError(throwable -> { Timber diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/EditClaim.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/EditClaim.kt index 5bc7eecc0..5d2ab7e38 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/EditClaim.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/EditClaim.kt @@ -11,19 +11,19 @@ data class EditClaim(val claims: List) { entityIds.forEach { list.add( Statement_partial( - Snak_partial( - "value", - propertyName, - DataValue.EntityId( + mainSnak = Snak_partial( + snakType = "value", + property = propertyName, + dataValue = DataValue.EntityId( WikiBaseEntityValue( - "item", - it, - it.removePrefix("Q").toLong() + entityType = "item", + id = it, + numericId = it.removePrefix("Q").toLong() ) ) ), - "statement", - "preferred" + type = "statement", + rank = "preferred" ) ) } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/RemoveClaim.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/RemoveClaim.kt new file mode 100644 index 000000000..1e553ae3e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/RemoveClaim.kt @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.wikidata.model + +data class RemoveClaim(val claims: List) { + companion object { + @JvmStatic + fun from(claimIds: List): RemoveClaim { + val claimsToRemove = mutableListOf() + + claimIds.forEach { + claimsToRemove.add( + ClaimRemoveRequest(id = it, remove = "") + ) + } + + return RemoveClaim(claimsToRemove) + } + } +} + +data class ClaimRemoveRequest(val id: String, val remove: String) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Snak_partial.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Snak_partial.kt index eb1412cf5..9ffe05434 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Snak_partial.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Snak_partial.kt @@ -5,15 +5,15 @@ import com.google.gson.annotations.SerializedName /*"mainsnak": { "snaktype": "value", "property": "P17", - "datatype": "wikibase-item", "datavalue": { "value": { - "entity-type": "item", - "id": "Q30", - "numeric-id": 30 - }, + "entity-type": "item", + "numeric-id": 30, + "id": "Q30" + }, "type": "wikibase-entityid" - } + }, + "datatype": "wikibase-item", }*/ data class Snak_partial( @SerializedName("snaktype") val snakType: String, diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Statement_partial.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Statement_partial.kt index aecd49b48..5bfb33e19 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Statement_partial.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Statement_partial.kt @@ -3,9 +3,9 @@ package fr.free.nrw.commons.wikidata.model import com.google.gson.annotations.SerializedName /*{ - "id": "q60$5083E43C-228B-4E3E-B82A-4CB20A22A3FB", "mainsnak": {}, "type": "statement", + "id": "q60$5083E43C-228B-4E3E-B82A-4CB20A22A3FB", "rank": "normal", "qualifiers": { "P580": [], diff --git a/app/src/test/kotlin/fr/free/nrw/commons/wikidata/WikiBaseClientUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/wikidata/WikiBaseClientUnitTest.kt index 956b5d4a0..9e9b2117d 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/wikidata/WikiBaseClientUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/wikidata/WikiBaseClientUnitTest.kt @@ -77,7 +77,7 @@ class WikiBaseClientUnitTest { "M123", "test", "en", "caption" )).thenReturn(Observable.just(mwPostResponse)) - val result = wikiBaseClient.addLabelstoWikidata(123L, "en", "caption").blockingFirst() + val result = wikiBaseClient.addLabelsToWikidata(123L, "en", "caption").blockingFirst() assertSame(mwPostResponse, result) }