From fc95f95c650896e60a6e83fc48a68c856231759f Mon Sep 17 00:00:00 2001
From: Patrick Geselbracht <code@patrick-geselbracht.eu>
Date: Mon, 6 Nov 2023 14:03:09 +0100
Subject: [PATCH 1/2] Add admin/retention methods (#334)

* Allow parsing of time strings with +00:00 as well as Z

* Add admin/retention methods
---
 .../rx/admin/RxAdminRetentionMethods.kt       |  40 +++++++
 .../kotlin/social/bigbone/JsonSerializer.kt   |   8 +-
 .../kotlin/social/bigbone/MastodonClient.kt   |   8 ++
 .../bigbone/api/entity/admin/AdminCohort.kt   |  81 +++++++++++++
 .../api/method/admin/AdminRetentionMethods.kt |  41 +++++++
 ...alculate_retention_data_daily_success.json |  71 +++++++++++
 ...culate_retention_data_monthly_success.json |  13 +++
 .../method/admin/AdminRetentionMethodsTest.kt | 110 ++++++++++++++++++
 8 files changed, 371 insertions(+), 1 deletion(-)
 create mode 100644 bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminRetentionMethods.kt
 create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCohort.kt
 create mode 100644 bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminRetentionMethods.kt
 create mode 100644 bigbone/src/test/assets/admin_retention_calculate_retention_data_daily_success.json
 create mode 100644 bigbone/src/test/assets/admin_retention_calculate_retention_data_monthly_success.json
 create mode 100644 bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminRetentionMethodsTest.kt

diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminRetentionMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminRetentionMethods.kt
new file mode 100644
index 000000000..abbc88dfc
--- /dev/null
+++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminRetentionMethods.kt
@@ -0,0 +1,40 @@
+package social.bigbone.rx.admin
+
+import io.reactivex.rxjava3.core.Single
+import social.bigbone.MastodonClient
+import social.bigbone.MastodonRequest
+import social.bigbone.api.entity.admin.AdminCohort
+import social.bigbone.api.entity.admin.AdminCohort.FrequencyOneOf
+import social.bigbone.api.method.admin.AdminRetentionMethods
+import java.time.Instant
+
+/**
+ * Reactive implementation of [AdminRetentionMethods].
+ *
+ * Show retention data over time.
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/retention/">Mastodon admin/retention API methods</a>
+ */
+class RxAdminRetentionMethods(private val client: MastodonClient) {
+
+    private val adminRetentionMethods = AdminRetentionMethods(client)
+
+    /**
+     * Generate a retention data report for a given time period and bucket.
+     *
+     * @param startAt The start date for the time period. If a time is provided, it will be ignored.
+     * @param endAt The end date for the time period. If a time is provided, it will be ignored.
+     * @param frequency Specify whether to use [FrequencyOneOf.DAY] or [FrequencyOneOf.MONTH] buckets.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/retention/#create">Mastodon API documentation: admin/retention/#create</a>
+     */
+    fun calculateRetentionData(
+        startAt: Instant,
+        endAt: Instant,
+        frequency: FrequencyOneOf
+    ): Single<MastodonRequest<List<AdminCohort>>> = Single.fromCallable {
+        adminRetentionMethods.calculateRetentionData(
+            startAt = startAt,
+            endAt = endAt,
+            frequency = frequency
+        )
+    }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/JsonSerializer.kt b/bigbone/src/main/kotlin/social/bigbone/JsonSerializer.kt
index 0dc39a82f..f9d201cce 100644
--- a/bigbone/src/main/kotlin/social/bigbone/JsonSerializer.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/JsonSerializer.kt
@@ -13,6 +13,7 @@ import social.bigbone.PrecisionDateTime.ValidPrecisionDateTime
 import java.time.Instant
 import java.time.LocalDate
 import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
 import java.time.format.DateTimeParseException
 
 internal val JSON_SERIALIZER: Json = Json {
@@ -62,7 +63,12 @@ object DateTimeSerializer : KSerializer<PrecisionDateTime> {
      * @param decodedString ISO 8601 string retrieved from JSON
      */
     private fun parseExactDateTime(decodedString: String): ValidPrecisionDateTime.ExactTime =
-        ValidPrecisionDateTime.ExactTime(Instant.parse(decodedString))
+        ValidPrecisionDateTime.ExactTime(
+            DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(
+                decodedString,
+                Instant::from
+            )
+        )
 
     /**
      * Attempts to parse an ISO 8601 string into a [LocalDate] and returning an [Instant] at the start of that day in UTC.
diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
index e8e17a897..6c25f6c9d 100644
--- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
@@ -43,6 +43,7 @@ import social.bigbone.api.method.StreamingMethods
 import social.bigbone.api.method.SuggestionMethods
 import social.bigbone.api.method.TagMethods
 import social.bigbone.api.method.TimelineMethods
+import social.bigbone.api.method.admin.AdminRetentionMethods
 import social.bigbone.extension.emptyRequestBody
 import social.bigbone.nodeinfo.NodeInfoClient
 import java.io.IOException
@@ -75,6 +76,13 @@ private constructor(
     @get:JvmName("accounts")
     val accounts: AccountMethods by lazy { AccountMethods(this) }
 
+    /**
+     * Access API methods under the "admin/retention" endpoint.
+     */
+    @Suppress("unused") // public API
+    @get:JvmName("adminRetention")
+    val adminRetention: AdminRetentionMethods by lazy { AdminRetentionMethods(this) }
+
     /**
      * Access API methods under the "announcements" endpoint.
      */
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCohort.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCohort.kt
new file mode 100644
index 000000000..0320bba52
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCohort.kt
@@ -0,0 +1,81 @@
+package social.bigbone.api.entity.admin
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import social.bigbone.DateTimeSerializer
+import social.bigbone.PrecisionDateTime
+
+/**
+ * Represents a retention metric.
+ * @see <a href="https://docs.joinmastodon.org/entities/Admin_Cohort/">Mastodon documentation Admin::Cohort</a>
+ *
+ */
+@Serializable
+data class AdminCohort(
+
+    /**
+     * The timestamp for the start of the period, at midnight.
+     */
+    @SerialName("period")
+    @Serializable(with = DateTimeSerializer::class)
+    val period: PrecisionDateTime = PrecisionDateTime.InvalidPrecisionDateTime.Unavailable,
+
+    /**
+     * The size of the bucket for the returned data.
+     */
+    @SerialName("frequency")
+    val frequency: FrequencyOneOf? = null,
+
+    /**
+     * Retention data for users who registered during the given period.
+     */
+    @SerialName("data")
+    val data: List<CohortData>? = null
+) {
+    /**
+     * The size of the bucket for the returned data.
+     */
+    @Serializable
+    enum class FrequencyOneOf {
+        /**
+         * Daily buckets.
+         */
+        @SerialName("day")
+        DAY,
+
+        /**
+         * Monthly buckets.
+         */
+        @SerialName("month")
+        MONTH;
+
+        @OptIn(ExperimentalSerializationApi::class)
+        val apiName: String get() = serializer().descriptor.getElementName(ordinal)
+    }
+
+    /**
+     * Retention data for users who registered during the given period.
+     */
+    @Serializable
+    data class CohortData(
+        /**
+         * The timestamp for the start of the bucket, at midnight.
+         */
+        @SerialName("date")
+        @Serializable(with = DateTimeSerializer::class)
+        val date: PrecisionDateTime = PrecisionDateTime.InvalidPrecisionDateTime.Unavailable,
+
+        /**
+         * The percentage rate of users who registered in the specified period and were active for the given date bucket.
+         */
+        @SerialName("rate")
+        val rate: Float? = null,
+
+        /**
+         * How many users registered in the specified period and were active for the given date bucket.
+         */
+        @SerialName("value")
+        val value: String? = null
+    )
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminRetentionMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminRetentionMethods.kt
new file mode 100644
index 000000000..b2d57de1d
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminRetentionMethods.kt
@@ -0,0 +1,41 @@
+package social.bigbone.api.method.admin
+
+import social.bigbone.MastodonClient
+import social.bigbone.MastodonRequest
+import social.bigbone.Parameters
+import social.bigbone.api.entity.admin.AdminCohort
+import social.bigbone.api.entity.admin.AdminCohort.FrequencyOneOf
+import java.time.Instant
+
+/**
+ * Show retention data over time.
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/retention/">Mastodon admin/retention API methods</a>
+ */
+class AdminRetentionMethods(private val client: MastodonClient) {
+
+    private val adminRetentionEndpoint = "api/v1/admin/retention"
+
+    /**
+     * Generate a retention data report for a given time period and bucket.
+     *
+     * @param startAt The start date for the time period. If a time is provided, it will be ignored.
+     * @param endAt The end date for the time period. If a time is provided, it will be ignored.
+     * @param frequency Specify whether to use [FrequencyOneOf.DAY] or [FrequencyOneOf.MONTH] buckets.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/retention/#create">Mastodon API documentation: admin/retention/#create</a>
+     */
+    fun calculateRetentionData(
+        startAt: Instant,
+        endAt: Instant,
+        frequency: FrequencyOneOf
+    ): MastodonRequest<List<AdminCohort>> {
+        return client.getMastodonRequestForList(
+            endpoint = adminRetentionEndpoint,
+            method = MastodonClient.Method.POST,
+            parameters = Parameters().apply {
+                append("start_at", startAt.toString())
+                append("end_at", endAt.toString())
+                append("frequency", frequency.apiName)
+            }
+        )
+    }
+}
diff --git a/bigbone/src/test/assets/admin_retention_calculate_retention_data_daily_success.json b/bigbone/src/test/assets/admin_retention_calculate_retention_data_daily_success.json
new file mode 100644
index 000000000..5706a0e2d
--- /dev/null
+++ b/bigbone/src/test/assets/admin_retention_calculate_retention_data_daily_success.json
@@ -0,0 +1,71 @@
+[
+  {
+    "period": "2022-09-08T00:00:00+00:00",
+    "frequency": "day",
+    "data": [
+      {
+        "date": "2022-09-08T00:00:00+00:00",
+        "rate": 1,
+        "value": "2"
+      },
+      {
+        "date": "2022-09-09T00:00:00+00:00",
+        "rate": 1,
+        "value": "2"
+      },
+      {
+        "date": "2022-09-10T00:00:00+00:00",
+        "rate": 0.5,
+        "value": "1"
+      },
+      {
+        "date": "2022-09-14T00:00:00+00:00",
+        "rate": 0.5,
+        "value": "1"
+      }
+    ]
+  },
+  {
+    "period": "2022-09-09T00:00:00+00:00",
+    "frequency": "day",
+    "data": [
+      {
+        "date": "2022-09-09T00:00:00+00:00",
+        "rate": 0,
+        "value": "0"
+      },
+      {
+        "date": "2022-09-14T00:00:00+00:00",
+        "rate": 0,
+        "value": "0"
+      }
+    ]
+  },
+  {
+    "period": "2022-09-10T00:00:00+00:00",
+    "frequency": "day",
+    "data": [
+      {
+        "date": "2022-09-10T00:00:00+00:00",
+        "rate": 0,
+        "value": "0"
+      },
+      {
+        "date": "2022-09-14T00:00:00+00:00",
+        "rate": 0,
+        "value": "0"
+      }
+    ]
+  },
+  {
+    "period": "2022-09-14T00:00:00+00:00",
+    "frequency": "day",
+    "data": [
+      {
+        "date": "2022-09-14T00:00:00+00:00",
+        "rate": 0,
+        "value": "0"
+      }
+    ]
+  }
+]
diff --git a/bigbone/src/test/assets/admin_retention_calculate_retention_data_monthly_success.json b/bigbone/src/test/assets/admin_retention_calculate_retention_data_monthly_success.json
new file mode 100644
index 000000000..2d5d26eed
--- /dev/null
+++ b/bigbone/src/test/assets/admin_retention_calculate_retention_data_monthly_success.json
@@ -0,0 +1,13 @@
+[
+  {
+    "period": "2022-09-01T00:00:00+00:00",
+    "frequency": "month",
+    "data": [
+      {
+        "date": "2022-09-01T00:00:00+00:00",
+        "rate": 1.0,
+        "value": "2"
+      }
+    ]
+  }
+]
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminRetentionMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminRetentionMethodsTest.kt
new file mode 100644
index 000000000..c0be3cd6c
--- /dev/null
+++ b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminRetentionMethodsTest.kt
@@ -0,0 +1,110 @@
+package social.bigbone.api.method.admin
+
+import io.mockk.slot
+import io.mockk.verify
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldHaveSize
+import org.amshove.kluent.shouldNotBeNull
+import org.junit.jupiter.api.Test
+import social.bigbone.Parameters
+import social.bigbone.PrecisionDateTime.ValidPrecisionDateTime.ExactTime
+import social.bigbone.api.entity.admin.AdminCohort
+import social.bigbone.testtool.MockClient
+import java.net.URLEncoder
+import java.time.LocalDate
+import java.time.ZoneOffset
+
+class AdminRetentionMethodsTest {
+
+    @Test
+    fun `Given client returning success, when getting calculated retention data with daily frequency, then call expected endpoint and return expected data`() {
+        val client = MockClient.mock("admin_retention_calculate_retention_data_daily_success.json")
+        val adminRetentionMethods = AdminRetentionMethods(client)
+        val startAt = LocalDate.of(2023, 7, 2).atStartOfDay(ZoneOffset.UTC).toInstant()
+        val endAt = LocalDate.of(2023, 7, 19).atStartOfDay(ZoneOffset.UTC).toInstant()
+
+        val calculatedRetentionData = adminRetentionMethods.calculateRetentionData(
+            startAt = startAt,
+            endAt = endAt,
+            frequency = AdminCohort.FrequencyOneOf.DAY
+        ).execute()
+        with(calculatedRetentionData) {
+            shouldHaveSize(4)
+
+            with(get(0)) {
+                period shouldBeEqualTo ExactTime(
+                    LocalDate.of(2022, 9, 8).atStartOfDay(ZoneOffset.UTC).toInstant()
+                )
+                frequency shouldBeEqualTo AdminCohort.FrequencyOneOf.DAY
+
+                with(data) {
+                    shouldNotBeNull()
+                    shouldHaveSize(4)
+
+                    get(0).rate shouldBeEqualTo 1.0f
+                    get(0).value shouldBeEqualTo "2"
+                }
+            }
+        }
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.post(
+                path = "api/v1/admin/retention",
+                body = capture(parametersCapturingSlot),
+                addIdempotencyKey = false
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            val startString = URLEncoder.encode(startAt.toString(), "utf-8")
+            val endString = URLEncoder.encode(endAt.toString(), "utf-8")
+            toQuery() shouldBeEqualTo "start_at=$startString&end_at=$endString&frequency=day"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when getting retention data with monthly frequency, then call expected endpoint and return expected data`() {
+        val client = MockClient.mock("admin_retention_calculate_retention_data_monthly_success.json")
+        val adminRetentionMethods = AdminRetentionMethods(client)
+        val startAt = LocalDate.of(2022, 8, 1).atStartOfDay(ZoneOffset.UTC).toInstant()
+        val endAt = LocalDate.of(2023, 10, 1).atStartOfDay(ZoneOffset.UTC).toInstant()
+
+        val calculatedRetentionData = adminRetentionMethods.calculateRetentionData(
+            startAt = startAt,
+            endAt = endAt,
+            frequency = AdminCohort.FrequencyOneOf.MONTH
+        ).execute()
+        with(calculatedRetentionData) {
+            shouldHaveSize(1)
+
+            with(get(0)) {
+                period shouldBeEqualTo ExactTime(
+                    LocalDate.of(2022, 9, 1).atStartOfDay(ZoneOffset.UTC).toInstant()
+                )
+                frequency shouldBeEqualTo AdminCohort.FrequencyOneOf.MONTH
+
+                with(data) {
+                    shouldNotBeNull()
+                    shouldHaveSize(1)
+
+                    get(0).rate shouldBeEqualTo 1.0f
+                    get(0).value shouldBeEqualTo "2"
+                }
+            }
+        }
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.post(
+                path = "api/v1/admin/retention",
+                body = capture(parametersCapturingSlot),
+                addIdempotencyKey = false
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            val startString = URLEncoder.encode(startAt.toString(), "utf-8")
+            val endString = URLEncoder.encode(endAt.toString(), "utf-8")
+            toQuery() shouldBeEqualTo "start_at=$startString&end_at=$endString&frequency=month"
+        }
+    }
+}

From 12d6648c6b2da33b433ad24c5f5701657cddc9c4 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 6 Nov 2023 20:35:32 +0000
Subject: [PATCH 2/2] Bump org.junit.platform:junit-platform-launcher from
 1.10.0 to 1.10.1

Bumps [org.junit.platform:junit-platform-launcher](https://github.com/junit-team/junit5) from 1.10.0 to 1.10.1.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/commits)

---
updated-dependencies:
- dependency-name: org.junit.platform:junit-platform-launcher
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 gradle/libs.versions.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 473c909d6..88043fd8e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,7 +4,7 @@ bigbone = "2.0.0-SNAPSHOT"
 
 # 3rd party dependencies
 junit-jupiter = "5.10.0"
-junit-platform-launcher = "1.10.0"
+junit-platform-launcher = "1.10.1"
 junit-platform-suite-engine = "1.10.0"
 kluent = "1.73"
 kotlin = "1.9.20"