From 35ad5dfbc439a94942a22f12daa4a420128d3e8d Mon Sep 17 00:00:00 2001 From: Shinokuni Date: Tue, 1 Aug 2023 21:40:47 +0200 Subject: [PATCH] Add FreshRSSDataSource kotlin migration with tests This new data source is intended to replace the old java one --- .../freshrss/NewFreshRSSDataSource.kt | 121 ++++++++ .../services/freshrss/NewFreshRSSService.kt | 73 +++++ .../freshrss/FreshRSSDataSourceTest.kt | 260 ++++++++++++++++++ .../services/freshrss/login_response_body | 3 + .../freshrss/writetoken_response_body | 1 + 5 files changed, 458 insertions(+) create mode 100644 api/src/main/java/com/readrops/api/services/freshrss/NewFreshRSSDataSource.kt create mode 100644 api/src/main/java/com/readrops/api/services/freshrss/NewFreshRSSService.kt create mode 100644 api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt create mode 100644 api/src/test/resources/services/freshrss/login_response_body create mode 100644 api/src/test/resources/services/freshrss/writetoken_response_body diff --git a/api/src/main/java/com/readrops/api/services/freshrss/NewFreshRSSDataSource.kt b/api/src/main/java/com/readrops/api/services/freshrss/NewFreshRSSDataSource.kt new file mode 100644 index 00000000..9f036827 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/freshrss/NewFreshRSSDataSource.kt @@ -0,0 +1,121 @@ +package com.readrops.api.services.freshrss + +import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo +import com.readrops.db.entities.Item +import okhttp3.MultipartBody +import java.io.StringReader +import java.util.Properties + +class NewFreshRSSDataSource(private val service: NewFreshRSSService) { + + suspend fun login(login: String, password: String): String { + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("Email", login) + .addFormDataPart("Passwd", password) + .build() + + val response = service.login(requestBody) + + val properties = Properties() + properties.load(StringReader(response.string())) + + response.close() + return properties.getProperty("Auth") + } + + suspend fun getWriteToken(): String = service.getWriteToken().string() + + suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo() + + suspend fun sync() { + + } + + suspend fun getFolders() = service.getFolders() + + suspend fun getFeeds() = service.getFeeds() + + suspend fun getItems(excludeTargets: List, max: Int, lastModified: Long): List { + return service.getItems(excludeTargets, max, lastModified) + } + + suspend fun getStarredItems(max: Int) = service.getStarredItems(max) + + suspend fun getItemsIds(excludeTarget: String, includeTarget: String, max: Int): List { + return service.getItemsIds(excludeTarget, includeTarget, max) + } + + private suspend fun setItemsReadState(read: Boolean, itemIds: List, token: String) { + return if (read) { + service.setItemsState(token, GOOGLE_READ, null, itemIds) + } else { + service.setItemsState(token, null, GOOGLE_READ, itemIds) + } + } + + private suspend fun setItemStarState(starred: Boolean, itemIds: List, token: String) { + return if (starred) { + service.setItemsState(token, GOOGLE_STARRED, null, itemIds) + } else { + service.setItemsState(token, null, GOOGLE_STARRED, itemIds) + } + } + + suspend fun createFeed(token: String, feedUrl: String) { + service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe"); + } + + suspend fun deleteFeed(token: String, feedUrl: String) { + service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe") + } + + suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) { + service.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit") + } + + suspend fun createFolder(token: String, tagName: String) { + service.createFolder(token, "$FOLDER_PREFIX$tagName") + } + + suspend fun updateFolder(token: String, folderId: String, name: String) { + service.updateFolder(token, folderId, "$FOLDER_PREFIX$name") + } + + suspend fun deleteFolder(token: String, folderId: String) { + service.deleteFolder(token, folderId) + } + + suspend fun setItemsReadState(syncData: FreshRSSSyncData, token: String) { + if (syncData.readItemsIds.isNotEmpty()) { + setItemsReadState(true, syncData.readItemsIds, token) + } + + if (syncData.unreadItemsIds.isNotEmpty()) { + setItemsReadState(false, syncData.unreadItemsIds, token) + } + } + + suspend fun setItemsStarState(syncData: FreshRSSSyncData, token: String) { + if (syncData.starredItemsIds.isNotEmpty()) { + setItemStarState(true, syncData.starredItemsIds, token) + } + + if (syncData.unstarredItemsIds.isNotEmpty()) { + setItemStarState(false, syncData.unstarredItemsIds, token) + } + } + + companion object { + private const val MAX_ITEMS = 2500 + private const val MAX_STARRED_ITEMS = 1000 + + const val GOOGLE_READ = "user/-/state/com.google/read" + const val GOOGLE_UNREAD = "user/-/state/com.google/unread" + const val GOOGLE_STARRED = "user/-/state/com.google/starred" + const val GOOGLE_READING_LIST = "user/-/state/com.google/reading-list" + + const val FEED_PREFIX = "feed/" + const val FOLDER_PREFIX = "user/-/label/" + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/freshrss/NewFreshRSSService.kt b/api/src/main/java/com/readrops/api/services/freshrss/NewFreshRSSService.kt new file mode 100644 index 00000000..243f8d44 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/freshrss/NewFreshRSSService.kt @@ -0,0 +1,73 @@ +package com.readrops.api.services.freshrss + +import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.http.Body +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface NewFreshRSSService { + + @POST("accounts/ClientLogin") + suspend fun login(@Body body: RequestBody?): ResponseBody + + @GET("reader/api/0/token") + suspend fun getWriteToken(): ResponseBody + + @GET("reader/api/0/user-info") + suspend fun userInfo(): FreshRSSUserInfo + + @GET("reader/api/0/subscription/list?output=json") + suspend fun getFeeds(): List + + @GET("reader/api/0/tag/list?output=json") + suspend fun getFolders(): List + + @GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list") + suspend fun getItems(@Query("xt") excludeTarget: List?, @Query("n") max: Int, + @Query("ot") lastModified: Long?): List + + @GET("reader/api/0/stream/contents/user/-/state/com.google/starred") + suspend fun getStarredItems(@Query("n") max: Int): List + + @GET("reader/api/0/stream/items/ids") + suspend fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?, + @Query("n") max: Int): List + + @FormUrlEncoded + @POST("reader/api/0/edit-tag") + suspend fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?, + @Field("r") removeAction: String?, @Field("i") itemIds: List) + + @FormUrlEncoded + @POST("reader/api/0/subscription/edit") + suspend fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String) + + @FormUrlEncoded + @POST("reader/api/0/subscription/edit") + suspend fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String, + @Field("a") folderId: String, @Field("ac") action: String) + + @FormUrlEncoded + @POST("reader/api/0/edit-tag") + suspend fun createFolder(@Field("T") token: String, @Field("a") tagName: String) + + @FormUrlEncoded + @POST("reader/api/0/rename-tag") + suspend fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String) + + @FormUrlEncoded + @POST("reader/api/0/disable-tag") + suspend fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String) + + companion object { + const val END_POINT = "/api/greader.php/" + } +} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt b/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt new file mode 100644 index 00000000..1cc882f1 --- /dev/null +++ b/api/src/test/java/com/readrops/api/services/freshrss/FreshRSSDataSourceTest.kt @@ -0,0 +1,260 @@ +package com.readrops.api.services.freshrss + +import com.readrops.api.TestUtils +import com.readrops.api.apiModule +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.get +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.net.HttpURLConnection +import java.net.URLEncoder +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FreshRSSDataSourceTest : KoinTest { + + private lateinit var freshRSSDataSource: NewFreshRSSDataSource + private val mockServer = MockWebServer() + + @get:Rule + val koinTestRule = KoinTestRule.create { + modules(apiModule, module { + single { + Retrofit.Builder() + .baseUrl("http://localhost:8080/") + .client(get()) + .addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi")))) + .build() + .create(NewFreshRSSService::class.java) + } + }) + } + + @Before + fun before() { + mockServer.start(8080) + freshRSSDataSource = NewFreshRSSDataSource(get()) + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun loginTest() { + runBlocking { + val responseBody = TestUtils.loadResource("services/freshrss/login_response_body") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(responseBody))) + + val authString = freshRSSDataSource.login("Login", "Password") + assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString) + + val request = mockServer.takeRequest() + val requestBody = request.body.readUtf8() + + assertTrue { + requestBody.contains("name=\"Email\"") && requestBody.contains("Login") + } + + assertTrue { + requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password") + } + } + } + + @Test + fun writeTokenTest() = runBlocking { + val responseBody = TestUtils.loadResource("services/freshrss/writetoken_response_body") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(responseBody))) + + val writeToken = freshRSSDataSource.getWriteToken() + + assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken) + } + + @Test + fun userInfoTest() = runBlocking { + + } + + @Test + fun foldersTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val folders = freshRSSDataSource.getFolders() + assertTrue { folders.size == 1 } + } + + @Test + fun feedsTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val feeds = freshRSSDataSource.getFeeds() + assertTrue { feeds.size == 1 } + } + + @Test + fun itemsTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/items.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val items = freshRSSDataSource.getItems(listOf(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_STARRED), 100, 21343321321321) + assertTrue { items.size == 2 } + + val request = mockServer.takeRequest() + + with(request.requestUrl!!) { + assertEquals(listOf(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_STARRED), queryParameterValues("xt")) + assertEquals("100", queryParameter("n")) + assertEquals("21343321321321", queryParameter("ot")) + + } + } + + @Test + fun starredItemsTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/items.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val items = freshRSSDataSource.getStarredItems(100) + assertTrue { items.size == 2 } + + val request = mockServer.takeRequest() + + assertEquals("100", request.requestUrl!!.queryParameter("n")) + } + + @Test + fun getItemsIdsTest() = runBlocking { + val stream = TestUtils.loadResource("services/freshrss/adapters/items_starred_ids.json") + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) + + val ids = freshRSSDataSource.getItemsIds(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_READING_LIST, 100) + assertTrue { ids.size == 5 } + + val request = mockServer.takeRequest() + with(request.requestUrl!!) { + assertEquals(NewFreshRSSDataSource.GOOGLE_READ, queryParameter("xt")) + assertEquals(NewFreshRSSDataSource.GOOGLE_READING_LIST, queryParameter("s")) + assertEquals("100", queryParameter("n")) + } + } + + @Test + fun createFeedTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.createFeed("token", "https://feed.url") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } + assertTrue { contains("ac=subscribe") } + } + } + + @Test + fun deleteFeedTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.deleteFeed("token", "https://feed.url") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } + assertTrue { contains("ac=unsubscribe") } + } + } + + @Test + fun updateFeedTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") } + assertTrue { contains("t=title") } + assertTrue { contains("a=folderId") } + assertTrue { contains("ac=edit") } + } + } + + @Test + fun createFolderTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.createFolder("token", "folder") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("a=${URLEncoder.encode("${NewFreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") } + } + } + + @Test + fun updateFolderTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.updateFolder("token", "folderId", "folder") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=folderId") } + assertTrue { contains("dest=${URLEncoder.encode("${NewFreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") } + } + } + + @Test + fun deleteFolderTest() = runBlocking { + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + + freshRSSDataSource.deleteFolder("token", "folderId") + val request = mockServer.takeRequest() + + with(request.body.readUtf8()) { + assertTrue { contains("T=token") } + assertTrue { contains("s=folderId") } + } + } +} \ No newline at end of file diff --git a/api/src/test/resources/services/freshrss/login_response_body b/api/src/test/resources/services/freshrss/login_response_body new file mode 100644 index 00000000..e6bf504c --- /dev/null +++ b/api/src/test/resources/services/freshrss/login_response_body @@ -0,0 +1,3 @@ +SID=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a +LSID=null +Auth=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a diff --git a/api/src/test/resources/services/freshrss/writetoken_response_body b/api/src/test/resources/services/freshrss/writetoken_response_body new file mode 100644 index 00000000..42765922 --- /dev/null +++ b/api/src/test/resources/services/freshrss/writetoken_response_body @@ -0,0 +1 @@ +PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg \ No newline at end of file