Repository: andregasser/bigbone (kotlin)

Issue:
Implement Admin Trends API
Mastodon's Admin Trends API allows an admin to view trending links, statuses and tags. The goal of this task is to implement the following endpoints of this API in BigBone:

- View trending links
- View trending statuses
- View trending tags

The Admin Reports API as it is documented by Mastodon can be found here:
https://docs.joinmastodon.org/methods/admin/trends/

Tip: A lot of other endpoints similar to this have already been implemented. In order to get an idea how to implement this, I suggest that you study the code upfront. Start with one of the `*Methods.kt` files.

Please consult the following documents to make sure your contribution meets our projects criteria:

- [Code of Conduct](https://github.com/andregasser/bigbone/blob/master/CODE_OF_CONDUCT.md)
- [Code Contribution Guidelines](https://github.com/andregasser/bigbone/blob/master/CONTRIBUTING.md)

Pull request:
# Description

Adds methods for Admins to get trending tags, statuses, and links.

> [!NOTE]
> We don’t have a good system yet for entities that have similar versions for admin- and non-admin variants, like in this case with `Tag`. `AdminTag` shares most of its properties with `Tag` but adds some additional ones.
> For now, I have just added a new entity with copies from `Tag` and added the missing ones to it.

Closes #326 

# Type of Change

- New feature
- Documentation

# Breaking Changes

- None

# How Has This Been Tested?

New unit tests for all three methods

# Mandatory Checklist

- [x] I ran `gradle check` and there were no errors reported
- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] All tests pass locally with my changes
- [x] I have added KDoc documentation to all public methods

# Optional Things To Check

The items below are some more things to check before asking other people to review your code.

- [x] In case you worked on a new feature: Did you also implement the reactive endpoint (bigbone-rx)?
- [x] In case you added new `*Methods` classes: Did you also reference it in the `MastodonClient` main class?
- [x] Did you also update the documentation in the `/docs` folder (e.g. API Coverage page)?

Patch:
diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminTrendMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminTrendMethods.kt
new file mode 100644
index 000000000..aee509ac2
--- /dev/null
+++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminTrendMethods.kt
@@ -0,0 +1,47 @@
+package social.bigbone.rx.admin
+
+import io.reactivex.rxjava3.core.Single
+import social.bigbone.MastodonClient
+import social.bigbone.api.entity.Status
+import social.bigbone.api.entity.TrendsLink
+import social.bigbone.api.entity.admin.AdminTag
+import social.bigbone.api.method.admin.AdminTrendMethods
+
+/**
+ * Reactive implementation of [AdminTrendMethods].
+ *
+ * Obtain data about trending links, statuses, and tags on the administered server.
+ *
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/trends/">Mastodon admin/trends API methods</a>
+ */
+class RxAdminTrendMethods(client: MastodonClient) {
+
+    private val adminTrendMethods = AdminTrendMethods(client)
+
+    /**
+     * Links that have been shared more than others, including unapproved and unreviewed links.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/trends/#links">Mastodon API documentation: admin/trends/#links</a>
+     */
+    fun getTrendingLinks(): Single<List<TrendsLink>> = Single.fromCallable {
+        adminTrendMethods.getTrendingLinks().execute()
+    }
+
+    /**
+     * Statuses that have been interacted with more than others, including unapproved and unreviewed statuses.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/trends/#statuses">Mastodon API documentation: admin/trends/#statuses</a>
+     */
+    fun getTrendingStatuses(): Single<List<Status>> = Single.fromCallable {
+        adminTrendMethods.getTrendingStatuses().execute()
+    }
+
+    /**
+     * Tags that are being used more frequently within the past week, including unapproved and unreviewed tags.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/trends/#tags">Mastodon API documentation: admin/trends/#tags</a>
+     */
+    fun getTrendingTags(): Single<List<AdminTag>> = Single.fromCallable {
+        adminTrendMethods.getTrendingTags().execute()
+    }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
index af62f96c1..c807c6782 100644
--- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
@@ -74,6 +74,7 @@ import social.bigbone.api.method.admin.AdminEmailDomainBlockMethods
 import social.bigbone.api.method.admin.AdminIpBlockMethods
 import social.bigbone.api.method.admin.AdminMeasureMethods
 import social.bigbone.api.method.admin.AdminRetentionMethods
+import social.bigbone.api.method.admin.AdminTrendMethods
 import social.bigbone.extension.emptyRequestBody
 import social.bigbone.nodeinfo.NodeInfoClient
 import social.bigbone.nodeinfo.entity.Server
@@ -173,6 +174,13 @@ private constructor(
     @get:JvmName("adminRetention")
     val adminRetention: AdminRetentionMethods by lazy { AdminRetentionMethods(this) }
 
+    /**
+     * Access API methods under the "admin/trends" endpoint.
+     */
+    @Suppress("unused") // public API
+    @get:JvmName("adminTrends")
+    val adminTrends: AdminTrendMethods by lazy { AdminTrendMethods(this) }
+
     /**
      * Access API methods under the "announcements" endpoint.
      */
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminTag.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminTag.kt
new file mode 100644
index 000000000..8d163a1ce
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminTag.kt
@@ -0,0 +1,65 @@
+package social.bigbone.api.entity.admin
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import social.bigbone.api.entity.Tag
+import social.bigbone.api.entity.data.History
+
+/**
+ * Represents a hashtag used within the content of a status.
+ *
+ * This version contains additional Admin-related attributes not available in [Tag].
+ *
+ * @see <a href="https://docs.joinmastodon.org/entities/Tag/#admin">Mastodon API Admin::Tag</a>
+ */
+@Serializable
+data class AdminTag(
+    /**
+     * The value of the hashtag after the # sign.
+     */
+    @SerialName("name")
+    val name: String = "",
+
+    /**
+     * A link to the hashtag on the instance.
+     */
+    @SerialName("url")
+    val url: String = "",
+
+    /**
+     * Usage statistics for given days (typically the past week).
+     */
+    @SerialName("history")
+    val history: List<History> = emptyList(),
+
+    /**
+     * Whether the current token’s authorized user is following this tag.
+     */
+    @SerialName("following")
+    val following: Boolean? = null,
+
+    /**
+     * The ID of the Tag in the database.
+     * String cast from an Integer, but not guaranteed to be a number.
+     */
+    @SerialName("id")
+    val id: String,
+
+    /**
+     * Whether the hashtag has been approved to the trend.
+     */
+    @SerialName("trendable")
+    val trendable: Boolean,
+
+    /**
+     * Whether the hashtag has not been disabled from auto-linking.
+     */
+    @SerialName("usable")
+    val usable: Boolean,
+
+    /**
+     * Whether the hashtag has not been reviewed yet to approve or deny its trending.
+     */
+    @SerialName("requires_review")
+    val requiresReview: Boolean
+)
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminTrendMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminTrendMethods.kt
new file mode 100644
index 000000000..792ab4a60
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminTrendMethods.kt
@@ -0,0 +1,53 @@
+package social.bigbone.api.method.admin
+
+import social.bigbone.MastodonClient
+import social.bigbone.MastodonRequest
+import social.bigbone.api.entity.Status
+import social.bigbone.api.entity.TrendsLink
+import social.bigbone.api.entity.admin.AdminTag
+
+/**
+ * Obtain data about trending links, statuses, and tags on the administered server.
+ *
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/trends/">Mastodon admin/trends API methods</a>
+ */
+class AdminTrendMethods(private val client: MastodonClient) {
+
+    private val adminTrendsEndpoint = "api/v1/admin/trends"
+
+    /**
+     * Links that have been shared more than others, including unapproved and unreviewed links.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/trends/#links">Mastodon API documentation: admin/trends/#links</a>
+     */
+    fun getTrendingLinks(): MastodonRequest<List<TrendsLink>> {
+        return client.getMastodonRequestForList(
+            endpoint = "$adminTrendsEndpoint/links",
+            method = MastodonClient.Method.GET
+        )
+    }
+
+    /**
+     * Statuses that have been interacted with more than others, including unapproved and unreviewed statuses.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/trends/#statuses">Mastodon API documentation: admin/trends/#statuses</a>
+     */
+    fun getTrendingStatuses(): MastodonRequest<List<Status>> {
+        return client.getMastodonRequestForList(
+            endpoint = "$adminTrendsEndpoint/statuses",
+            method = MastodonClient.Method.GET
+        )
+    }
+
+    /**
+     * Tags that are being used more frequently within the past week, including unapproved and unreviewed tags.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/trends/#tags">Mastodon API documentation: admin/trends/#tags</a>
+     */
+    fun getTrendingTags(): MastodonRequest<List<AdminTag>> {
+        return client.getMastodonRequestForList(
+            endpoint = "$adminTrendsEndpoint/tags",
+            method = MastodonClient.Method.GET
+        )
+    }
+}
diff --git a/docs/api-coverage/admin/trends.md b/docs/api-coverage/admin/trends.md
index fca85090e..0e7e64f2f 100644
--- a/docs/api-coverage/admin/trends.md
+++ b/docs/api-coverage/admin/trends.md
@@ -10,8 +10,8 @@ nav_order: 4
 
 <a href="https://docs.joinmastodon.org/methods/admin/trends/" target="_blank">https://docs.joinmastodon.org/methods/admin/trends/</a>
 
-| Method                              | Description            | Status | Comments | 
-|-------------------------------------|------------------------|--------|----------|
-| `GET /api/v1/admin/trends/links`    | View trending links    | 🔴     |          |
-| `GET /api/v1/admin/trends/statuses` | View trending statuses | 🔴     |          |
-| `GET /api/v1/admin/trends/tags`     | View trending tags     | 🔴     |          |
+| Method                              | Description            | Status                          | Comments        | 
+|-------------------------------------|------------------------|---------------------------------|-----------------|
+| `GET /api/v1/admin/trends/links`    | View trending links    | <img src="/assets/green16.png"> | Fully supported |
+| `GET /api/v1/admin/trends/statuses` | View trending statuses | <img src="/assets/green16.png"> | Fully supported |
+| `GET /api/v1/admin/trends/tags`     | View trending tags     | <img src="/assets/green16.png"> | Fully supported |


Test patch:
diff --git a/bigbone/src/test/assets/admin_trends_trending_links_success.json b/bigbone/src/test/assets/admin_trends_trending_links_success.json
new file mode 100644
index 000000000..630eb78eb
--- /dev/null
+++ b/bigbone/src/test/assets/admin_trends_trending_links_success.json
@@ -0,0 +1,55 @@
+[
+  {
+    "url": "https://twitter.com/arufa_faru/status/1594262272007753728",
+    "title": "ARuFa on Twitter",
+    "description": "“言葉をマネするぬいぐるみを改造してエレキギターを直接繋いでみました”",
+    "type": "link",
+    "author_name": "",
+    "author_url": "",
+    "provider_name": "Twitter",
+    "provider_url": "",
+    "html": "",
+    "width": 400,
+    "height": 225,
+    "image": "https://media.chitter.xyz/cache/preview_cards/images/002/162/720/original/b5360261e8ce17fc.jpeg",
+    "embed_url": "",
+    "blurhash": "UNFiDM~o-oD%x[xtaxM|xaNHRkjsoft7ofWB",
+    "history": [
+      {
+        "day": "1669507200",
+        "accounts": "9",
+        "uses": "9"
+      },
+      {
+        "day": "1669420800",
+        "accounts": "0",
+        "uses": "0"
+      },
+      {
+        "day": "1669334400",
+        "accounts": "0",
+        "uses": "0"
+      },
+      {
+        "day": "1669248000",
+        "accounts": "0",
+        "uses": "0"
+      },
+      {
+        "day": "1669161600",
+        "accounts": "0",
+        "uses": "0"
+      },
+      {
+        "day": "1669075200",
+        "accounts": "0",
+        "uses": "0"
+      },
+      {
+        "day": "1668988800",
+        "accounts": "0",
+        "uses": "0"
+      }
+    ]
+  }
+]
diff --git a/bigbone/src/test/assets/admin_trends_trending_statuses_success.json b/bigbone/src/test/assets/admin_trends_trending_statuses_success.json
new file mode 100644
index 000000000..7e15ee75f
--- /dev/null
+++ b/bigbone/src/test/assets/admin_trends_trending_statuses_success.json
@@ -0,0 +1,17 @@
+[
+  {
+    "id": "109415512969053017",
+    "created_at": "2022-11-27T11:23:52.000Z",
+    "in_reply_to_id": null,
+    "in_reply_to_account_id": null,
+    "account": {
+      "id": "109332240210946752"
+    },
+    "media_attachments": [],
+    "mentions": [],
+    "tags": [],
+    "emojis": [],
+    "card": null,
+    "poll": null
+  }
+]
diff --git a/bigbone/src/test/assets/admin_trends_trending_tags_success.json b/bigbone/src/test/assets/admin_trends_trending_tags_success.json
new file mode 100644
index 000000000..245d0125a
--- /dev/null
+++ b/bigbone/src/test/assets/admin_trends_trending_tags_success.json
@@ -0,0 +1,47 @@
+[
+  {
+    "name": "caturday",
+    "url": "https://mastodon.example/tags/caturday",
+    "history": [
+      {
+        "day": "1669507200",
+        "accounts": "53",
+        "uses": "56"
+      },
+      {
+        "day": "1669420800",
+        "accounts": "142",
+        "uses": "171"
+      },
+      {
+        "day": "1669334400",
+        "accounts": "11",
+        "uses": "11"
+      },
+      {
+        "day": "1669248000",
+        "accounts": "8",
+        "uses": "9"
+      },
+      {
+        "day": "1669161600",
+        "accounts": "8",
+        "uses": "20"
+      },
+      {
+        "day": "1669075200",
+        "accounts": "11",
+        "uses": "11"
+      },
+      {
+        "day": "1668988800",
+        "accounts": "17",
+        "uses": "22"
+      }
+    ],
+    "id": "802",
+    "trendable": true,
+    "usable": true,
+    "requires_review": false
+  }
+]
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminTrendMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminTrendMethodsTest.kt
new file mode 100644
index 000000000..5c222d6e1
--- /dev/null
+++ b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminTrendMethodsTest.kt
@@ -0,0 +1,129 @@
+package social.bigbone.api.method.admin
+
+import io.mockk.verify
+import org.amshove.kluent.shouldBeBlank
+import org.amshove.kluent.shouldBeEmpty
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldBeFalse
+import org.amshove.kluent.shouldBeNull
+import org.amshove.kluent.shouldBeTrue
+import org.amshove.kluent.shouldHaveSize
+import org.amshove.kluent.shouldNotBeNull
+import org.amshove.kluent.shouldNotBeNullOrBlank
+import org.junit.jupiter.api.Test
+import social.bigbone.PrecisionDateTime.ValidPrecisionDateTime.ExactTime
+import social.bigbone.api.entity.PreviewCard
+import social.bigbone.api.entity.Status
+import social.bigbone.api.entity.TrendsLink
+import social.bigbone.api.entity.admin.AdminTag
+import social.bigbone.testtool.MockClient
+import java.time.Instant
+
+class AdminTrendMethodsTest {
+
+    @Test
+    fun `Given client returning success, when getting trending links, then ensure parsing and correct endpoint`() {
+        val client = MockClient.mock("admin_trends_trending_links_success.json")
+        val adminTrendMethods = AdminTrendMethods(client)
+
+        val trendingLinks: List<TrendsLink> = adminTrendMethods.getTrendingLinks().execute()
+        with(trendingLinks) {
+            shouldHaveSize(1)
+
+            with(get(0)) {
+                url.shouldNotBeNullOrBlank()
+                title shouldBeEqualTo "ARuFa on Twitter"
+                description.shouldNotBeNullOrBlank()
+                type shouldBeEqualTo PreviewCard.CardType.LINK
+                authorName?.shouldBeBlank()
+                authorUrl?.shouldBeBlank()
+                providerName shouldBeEqualTo "Twitter"
+                providerUrl?.shouldBeBlank()
+                html?.shouldBeBlank()
+                width shouldBeEqualTo 400
+                height shouldBeEqualTo 225
+                image.shouldNotBeNullOrBlank()
+                embedUrl?.shouldBeBlank()
+                blurhash shouldBeEqualTo "UNFiDM~o-oD%x[xtaxM|xaNHRkjsoft7ofWB"
+
+                history?.shouldHaveSize(7)
+                history?.get(0)?.day shouldBeEqualTo "1669507200"
+                history?.get(0)?.accounts shouldBeEqualTo "9"
+                history?.get(0)?.uses shouldBeEqualTo "9"
+            }
+        }
+
+        verify {
+            client.get(
+                path = "api/v1/admin/trends/links",
+                query = null
+            )
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when getting trending statuses, then ensure parsing and correct endpoint`() {
+        val client = MockClient.mock("admin_trends_trending_statuses_success.json")
+        val adminTrendMethods = AdminTrendMethods(client)
+
+        val trendingStatuses: List<Status> = adminTrendMethods.getTrendingStatuses().execute()
+        with(trendingStatuses) {
+            shouldHaveSize(1)
+
+            with(get(0)) {
+                id shouldBeEqualTo "109415512969053017"
+                createdAt shouldBeEqualTo ExactTime(Instant.parse("2022-11-27T11:23:52.000Z"))
+                inReplyToId.shouldBeNull()
+                inReplyToAccountId.shouldBeNull()
+                account.shouldNotBeNull()
+                account?.id shouldBeEqualTo "109332240210946752"
+                mediaAttachments.shouldBeEmpty()
+                mentions.shouldBeEmpty()
+                tags.shouldBeEmpty()
+                emojis.shouldBeEmpty()
+                card.shouldBeNull()
+                poll.shouldBeNull()
+            }
+        }
+
+        verify {
+            client.get(
+                path = "api/v1/admin/trends/statuses",
+                query = null
+            )
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when getting trending tags, then ensure parsing and correct endpoint`() {
+        val client = MockClient.mock("admin_trends_trending_tags_success.json")
+        val adminTrendMethods = AdminTrendMethods(client)
+
+        val trendingTags: List<AdminTag> = adminTrendMethods.getTrendingTags().execute()
+        with(trendingTags) {
+            shouldHaveSize(1)
+
+            with(get(0)) {
+                name shouldBeEqualTo "caturday"
+                url shouldBeEqualTo "https://mastodon.example/tags/caturday"
+
+                history.shouldHaveSize(7)
+                history[0].day shouldBeEqualTo "1669507200"
+                history[0].accounts shouldBeEqualTo "53"
+                history[0].uses shouldBeEqualTo "56"
+
+                id shouldBeEqualTo "802"
+                trendable.shouldBeTrue()
+                usable.shouldBeTrue()
+                requiresReview.shouldBeFalse()
+            }
+        }
+
+        verify {
+            client.get(
+                path = "api/v1/admin/trends/tags",
+                query = null
+            )
+        }
+    }
+}