Repository: andregasser/bigbone (kotlin)

Issue:
Implement Scheduled Statuses API
Mastodon’s [Scheduled Statuses API](https://docs.joinmastodon.org/methods/scheduled_statuses) allows users to view, update, and cancel scheduled statuses.

The goal of this task is to implement the following endpoints of this API in BigBone:

* [View scheduled statuses](https://docs.joinmastodon.org/methods/scheduled_statuses/#get)
* [View a single scheduled status](https://docs.joinmastodon.org/methods/scheduled_statuses/#get-one)
* [Update a scheduled status’s publishing date](https://docs.joinmastodon.org/methods/scheduled_statuses/#update)
* [Cancel a scheduled status](https://docs.joinmastodon.org/methods/scheduled_statuses/#cancel)

The Scheduled Statuses API as documented by Mastodon can be found here:
[docs.joinmastodon.org/methods/scheduled_statuses](https://docs.joinmastodon.org/methods/scheduled_statuses/)

> [!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?rgh-link-date=2023-11-03T07%3A22%3A10Z)

[Code Contribution Guidelines](https://github.com/andregasser/bigbone/blob/master/CONTRIBUTING.md?rgh-link-date=2023-11-03T07%3A22%3A10Z)

Pull request description:
# Description

Implements the previously missing methods of the [`scheduled_statuses` API](https://docs.joinmastodon.org/methods/scheduled_statuses/).

Closes #407.

# Type of Change

- New feature
- Documentation

# Breaking Changes

- `ScheduledStatus.spoilerText` is now nullable and will no longer default to an empty string if not available in the Mastodon API response
- `ScheduledStatus.visibility` is now nullable and will no longer default to `Visibility.PUBLIC` if not available or `null` in the Mastodon API response

# How Has This Been Tested?

New unit tests

# 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 checks

- [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/RxScheduledStatusMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxScheduledStatusMethods.kt
new file mode 100644
index 000000000..d1442701b
--- /dev/null
+++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxScheduledStatusMethods.kt
@@ -0,0 +1,72 @@
+package social.bigbone.rx.admin
+
+import io.reactivex.rxjava3.core.Completable
+import io.reactivex.rxjava3.core.Single
+import social.bigbone.MastodonClient
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.ScheduledStatus
+import social.bigbone.api.method.ScheduledStatusMethods
+import java.time.Instant
+
+/**
+ * Reactive implementation of [ScheduledStatusMethods].
+ *
+ * Manage statuses that were scheduled to be published at a future date.
+ *
+ * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/">Mastodon scheduled_statuses API methods</a>
+ */
+class RxScheduledStatusMethods(client: MastodonClient) {
+
+    private val scheduledStatusMethods = ScheduledStatusMethods(client)
+
+    /**
+     * View scheduled statuses.
+     *
+     * @param range optional Range for the pageable return value.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/#get">Mastodon API documentation: methods/scheduled_statuses/#get</a>
+     */
+    @JvmOverloads
+    fun getScheduledStatuses(range: Range = Range()): Single<Pageable<ScheduledStatus>> = Single.fromCallable {
+        scheduledStatusMethods.getScheduledStatuses(range).execute()
+    }
+
+    /**
+     * View a single scheduled status.
+     *
+     * @param withId The ID of the [ScheduledStatus] in the database.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/#get-one">Mastodon API documentation: methods/scheduled_statuses/#get-one</a>
+     */
+    fun getScheduledStatus(withId: String): Single<ScheduledStatus> = Single.fromCallable {
+        scheduledStatusMethods.getScheduledStatus(withId).execute()
+    }
+
+    /**
+     * Update a scheduled status’ publishing date.
+     *
+     * @param ofId The ID of the [ScheduledStatus] in the database.
+     * @param newPublishingDate Datetime at which the status will be published. Must lie at least 5 minutes ahead.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/#update">Mastodon API documentation: methods/scheduled_statuses/#update</a>
+     * @throws IllegalArgumentException if [newPublishingDate] is not at least 5 minutes ahead.
+     */
+    fun updatePublishingDate(
+        ofId: String,
+        newPublishingDate: Instant
+    ): Single<ScheduledStatus> = Single.fromCallable {
+        scheduledStatusMethods.updatePublishingDate(ofId, newPublishingDate).execute()
+    }
+
+    /**
+     * Cancel a scheduled status.
+     *
+     * @param withId The ID of the [ScheduledStatus] in the database.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/#cancel">Mastodon API documentation: methods/scheduled_statuses/#cancel</a>
+     */
+    fun cancelScheduledStatus(withId: String): Completable = Completable.fromAction {
+        scheduledStatusMethods.cancelScheduledStatus(withId)
+    }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
index c807c6782..04507a57f 100644
--- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
@@ -58,6 +58,7 @@ import social.bigbone.api.method.PollMethods
 import social.bigbone.api.method.PreferenceMethods
 import social.bigbone.api.method.PushNotificationMethods
 import social.bigbone.api.method.ReportMethods
+import social.bigbone.api.method.ScheduledStatusMethods
 import social.bigbone.api.method.SearchMethods
 import social.bigbone.api.method.StatusMethods
 import social.bigbone.api.method.StreamingMethods
@@ -370,6 +371,13 @@ private constructor(
     @get:JvmName("reports")
     val reports: ReportMethods by lazy { ReportMethods(this) }
 
+    /**
+     * Access API methods under the "scheduled_statuses" endpoint.
+     */
+    @Suppress("unused") // public API
+    @get:JvmName("scheduledStatuses")
+    val scheduledStatuses: ScheduledStatusMethods by lazy { ScheduledStatusMethods(this) }
+
     /**
      * Access API methods under the "search" endpoint.
      */
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/ScheduledStatus.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/ScheduledStatus.kt
index b7b68755a..a9460d40a 100644
--- a/bigbone/src/main/kotlin/social/bigbone/api/entity/ScheduledStatus.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/ScheduledStatus.kt
@@ -16,6 +16,7 @@ import social.bigbone.api.entity.data.Visibility
 data class ScheduledStatus(
     /**
      * ID of the scheduled status in the database.
+     * String cast from an Integer, but not guaranteed to be a number.
      */
     @SerialName("id")
     val id: String = "",
@@ -73,13 +74,13 @@ data class ScheduledStatus(
          * The text of the content warning or summary for the status.
          */
         @SerialName("spoiler_text")
-        val spoilerText: String = "",
+        val spoilerText: String? = null,
 
         /**
          * The visibility that the status will have once it is posted.
          */
         @SerialName("visibility")
-        val visibility: Visibility = Visibility.PUBLIC,
+        val visibility: Visibility? = null,
 
         /**
          * ID of the Status that will be replied to.
@@ -88,7 +89,8 @@ data class ScheduledStatus(
         val inReplyToId: String? = null,
 
         /**
-         * The language that will be used for the status (ISO 639-1 two-letter language code).
+         * The language that will be used for the status.
+         * String representing the ISO 639-1 two-letter language code.
          */
         @SerialName("language")
         val language: String? = null,
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/ScheduledStatusMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/ScheduledStatusMethods.kt
new file mode 100644
index 000000000..34c096ae2
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/ScheduledStatusMethods.kt
@@ -0,0 +1,97 @@
+package social.bigbone.api.method
+
+import social.bigbone.MastodonClient
+import social.bigbone.MastodonClient.Method
+import social.bigbone.MastodonRequest
+import social.bigbone.Parameters
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.ScheduledStatus
+import java.time.Duration
+import java.time.Instant
+
+/**
+ * Minimum [Duration] a scheduled status needs to be scheduled into the future.
+ */
+private val SCHEDULED_AT_MIN_AHEAD: Duration = Duration.ofMinutes(5)
+
+/**
+ * Manage statuses that were scheduled to be published at a future date.
+ *
+ * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/">Mastodon scheduled_statuses API methods</a>
+ */
+class ScheduledStatusMethods(private val client: MastodonClient) {
+
+    private val endpoint = "api/v1/scheduled_statuses"
+
+    /**
+     * View scheduled statuses.
+     *
+     * @param range optional Range for the pageable return value.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/#get">Mastodon API documentation: methods/scheduled_statuses/#get</a>
+     */
+    @JvmOverloads
+    fun getScheduledStatuses(range: Range = Range()): MastodonRequest<Pageable<ScheduledStatus>> {
+        return client.getPageableMastodonRequest<ScheduledStatus>(
+            endpoint = endpoint,
+            method = Method.GET,
+            parameters = range.toParameters()
+        )
+    }
+
+    /**
+     * View a single scheduled status.
+     *
+     * @param withId The ID of the [ScheduledStatus] in the database.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/#get-one">Mastodon API documentation: methods/scheduled_statuses/#get-one</a>
+     */
+    fun getScheduledStatus(withId: String): MastodonRequest<ScheduledStatus> {
+        return client.getMastodonRequest(
+            endpoint = "$endpoint/$withId",
+            method = Method.GET
+        )
+    }
+
+    /**
+     * Update a scheduled status’ publishing date.
+     *
+     * @param ofId The ID of the [ScheduledStatus] in the database.
+     * @param newPublishingDate Datetime at which the status will be published.
+     * Must lie at least [SCHEDULED_AT_MIN_AHEAD] minutes ahead.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/#update">Mastodon API documentation: methods/scheduled_statuses/#update</a>
+     * @throws IllegalArgumentException if [newPublishingDate] is not at least [SCHEDULED_AT_MIN_AHEAD] minutes ahead.
+     */
+    fun updatePublishingDate(
+        ofId: String,
+        newPublishingDate: Instant
+    ): MastodonRequest<ScheduledStatus> {
+        require(newPublishingDate.isAfter(Instant.now().plus(SCHEDULED_AT_MIN_AHEAD))) {
+            "New publishing date must lie ahead at least ${SCHEDULED_AT_MIN_AHEAD.toMinutes()} minutes"
+        }
+
+        return client.getMastodonRequest(
+            endpoint = "$endpoint/$ofId",
+            method = Method.PUT,
+            parameters = Parameters().apply {
+                append("scheduled_at", newPublishingDate.toString())
+            }
+        )
+    }
+
+    /**
+     * Cancel a scheduled status.
+     *
+     * @param withId The ID of the [ScheduledStatus] in the database.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/scheduled_statuses/#cancel">Mastodon API documentation: methods/scheduled_statuses/#cancel</a>
+     */
+    fun cancelScheduledStatus(withId: String) {
+        return client.performAction(
+            endpoint = "$endpoint/$withId",
+            method = Method.DELETE
+        )
+    }
+}
diff --git a/docs/api-coverage/scheduled-statuses.md b/docs/api-coverage/scheduled-statuses.md
index cf9a57a35..1c6ba76be 100644
--- a/docs/api-coverage/scheduled-statuses.md
+++ b/docs/api-coverage/scheduled-statuses.md
@@ -19,22 +19,22 @@ Manage statuses that were scheduled to be published at a future date.
   </tr>
   <tr>
     <td style="width:45%;text-align:left;"><code>GET /api/v1/scheduled_statuses</code><br>View scheduled statuses</td>
-    <td style="width:10%;text-align:center;"><img src="/assets/red16.png"></td>
-    <td style="width:45%;text-align:left;">Not yet implemented.</td>
+    <td style="width:10%;text-align:center;"><img src="/assets/green16.png"></td>
+    <td style="width:45%;text-align:left;">Fully supported.</td>
   </tr>
   <tr>
     <td style="width:45%;text-align:left;"><code>GET /api/v1/scheduled_statuses/:id</code><br>View a single scheduled status</td>
-    <td style="width:10%;text-align:center;"><img src="/assets/red16.png"></td>
-    <td style="width:45%;text-align:left;">Not yet implemented.</td>
+    <td style="width:10%;text-align:center;"><img src="/assets/green16.png"></td>
+    <td style="width:45%;text-align:left;">Fully supported.</td>
   </tr>
   <tr>
     <td style="width:45%;text-align:left;"><code>PUT /api/v1/scheduled_statuses/:id</code><br>Update a scheduled status's publishing date</td>
-    <td style="width:10%;text-align:center;"><img src="/assets/red16.png"></td>
-    <td style="width:45%;text-align:left;">Not yet implemented.</td>
+    <td style="width:10%;text-align:center;"><img src="/assets/green16.png"></td>
+    <td style="width:45%;text-align:left;">Fully supported.</td>
   </tr>
   <tr>
     <td style="width:45%;text-align:left;"><code>DELETE /api/v1/scheduled_statuses/:id</code><br>Cancel a scheduled status</td>
-    <td style="width:10%;text-align:center;"><img src="/assets/red16.png"></td>
-    <td style="width:45%;text-align:left;">Not yet implemented.</td>
+    <td style="width:10%;text-align:center;"><img src="/assets/green16.png"></td>
+    <td style="width:45%;text-align:left;">Fully supported.</td>
   </tr>
 </table>


Test patch:
diff --git a/bigbone/src/test/assets/scheduled_statuses_get_status_success.json b/bigbone/src/test/assets/scheduled_statuses_get_status_success.json
new file mode 100644
index 000000000..d6355f223
--- /dev/null
+++ b/bigbone/src/test/assets/scheduled_statuses_get_status_success.json
@@ -0,0 +1,17 @@
+{
+  "id": "3221",
+  "scheduled_at": "2019-12-05T12:33:01.000Z",
+  "params": {
+    "poll": null,
+    "text": "test content",
+    "media_ids": null,
+    "sensitive": null,
+    "visibility": null,
+    "idempotency": null,
+    "scheduled_at": null,
+    "spoiler_text": null,
+    "application_id": 596551,
+    "in_reply_to_id": null
+  },
+  "media_attachments": []
+}
diff --git a/bigbone/src/test/assets/scheduled_statuses_get_statuses_success.json b/bigbone/src/test/assets/scheduled_statuses_get_statuses_success.json
new file mode 100644
index 000000000..55725c7b7
--- /dev/null
+++ b/bigbone/src/test/assets/scheduled_statuses_get_statuses_success.json
@@ -0,0 +1,19 @@
+[
+  {
+    "id": "3221",
+    "scheduled_at": "2019-12-05T12:33:01.000Z",
+    "params": {
+      "poll": null,
+      "text": "test content",
+      "media_ids": null,
+      "sensitive": null,
+      "visibility": null,
+      "idempotency": null,
+      "scheduled_at": null,
+      "spoiler_text": null,
+      "application_id": 596551,
+      "in_reply_to_id": null
+    },
+    "media_attachments": []
+  }
+]
diff --git a/bigbone/src/test/assets/scheduled_statuses_update_success.json b/bigbone/src/test/assets/scheduled_statuses_update_success.json
new file mode 100644
index 000000000..634f747be
--- /dev/null
+++ b/bigbone/src/test/assets/scheduled_statuses_update_success.json
@@ -0,0 +1,17 @@
+{
+  "id": "3221",
+  "scheduled_at": "2019-12-05T13:33:01.000Z",
+  "params": {
+    "poll": null,
+    "text": "test content",
+    "media_ids": null,
+    "sensitive": null,
+    "visibility": null,
+    "idempotency": null,
+    "scheduled_at": null,
+    "spoiler_text": null,
+    "application_id": 596551,
+    "in_reply_to_id": null
+  },
+  "media_attachments": []
+}
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/ScheduledStatusMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/ScheduledStatusMethodsTest.kt
new file mode 100644
index 000000000..7b376b463
--- /dev/null
+++ b/bigbone/src/test/kotlin/social/bigbone/api/method/ScheduledStatusMethodsTest.kt
@@ -0,0 +1,233 @@
+package social.bigbone.api.method
+
+import io.mockk.Called
+import io.mockk.slot
+import io.mockk.verify
+import org.amshove.kluent.AnyException
+import org.amshove.kluent.invoking
+import org.amshove.kluent.shouldBeBlank
+import org.amshove.kluent.shouldBeEmpty
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldBeNull
+import org.amshove.kluent.shouldHaveSize
+import org.amshove.kluent.shouldNotThrow
+import org.amshove.kluent.shouldThrow
+import org.amshove.kluent.withMessage
+import org.junit.jupiter.api.Test
+import social.bigbone.MastodonClient.Method
+import social.bigbone.Parameters
+import social.bigbone.PrecisionDateTime.InvalidPrecisionDateTime
+import social.bigbone.PrecisionDateTime.ValidPrecisionDateTime.ExactTime
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.ScheduledStatus
+import social.bigbone.testtool.MockClient
+import social.bigbone.testtool.TestUtil.urlEncode
+import java.time.Duration
+import java.time.Instant
+
+class ScheduledStatusMethodsTest {
+
+    @Test
+    fun `Given a client returning success, when getting scheduled statuses without explicit Range, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("scheduled_statuses_get_statuses_success.json")
+        val scheduledStatusMethods = ScheduledStatusMethods(client)
+
+        val scheduledStatuses: Pageable<ScheduledStatus> = scheduledStatusMethods.getScheduledStatuses().execute()
+
+        with(scheduledStatuses.part) {
+            shouldHaveSize(1)
+
+            with(get(0)) {
+                id shouldBeEqualTo "3221"
+                scheduledAt shouldBeEqualTo ExactTime(Instant.parse("2019-12-05T12:33:01.000Z"))
+
+                with(params) {
+                    poll.shouldBeNull()
+                    text shouldBeEqualTo "test content"
+                    mediaIds.shouldBeNull()
+                    sensitive.shouldBeNull()
+                    visibility.shouldBeNull()
+                    idempotency.shouldBeNull()
+                    scheduledAt shouldBeEqualTo InvalidPrecisionDateTime.Unavailable
+                    spoilerText.shouldBeNull()
+                    applicationId shouldBeEqualTo 596_551
+                    inReplyToId.shouldBeNull()
+                }
+
+                mediaAttachments.shouldBeEmpty()
+            }
+        }
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.get(
+                path = "api/v1/scheduled_statuses",
+                query = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery().shouldBeBlank()
+        }
+    }
+
+    @Test
+    fun `Given a client returning success, when getting scheduled statuses with explicit Range, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("scheduled_statuses_get_statuses_success.json")
+        val scheduledStatusMethods = ScheduledStatusMethods(client)
+        val range = Range(limit = 56)
+
+        val scheduledStatuses: Pageable<ScheduledStatus> = scheduledStatusMethods.getScheduledStatuses(range).execute()
+
+        with(scheduledStatuses.part) {
+            shouldHaveSize(1)
+
+            with(get(0)) {
+                id shouldBeEqualTo "3221"
+                scheduledAt shouldBeEqualTo ExactTime(Instant.parse("2019-12-05T12:33:01.000Z"))
+
+                with(params) {
+                    poll.shouldBeNull()
+                    text shouldBeEqualTo "test content"
+                    mediaIds.shouldBeNull()
+                    sensitive.shouldBeNull()
+                    visibility.shouldBeNull()
+                    idempotency.shouldBeNull()
+                    scheduledAt shouldBeEqualTo InvalidPrecisionDateTime.Unavailable
+                    spoilerText.shouldBeNull()
+                    applicationId shouldBeEqualTo 596_551
+                    inReplyToId.shouldBeNull()
+                }
+
+                mediaAttachments.shouldBeEmpty()
+            }
+        }
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.get(
+                path = "api/v1/scheduled_statuses",
+                query = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "limit=56"
+        }
+    }
+
+    @Test
+    fun `Given a client returning success, when getting scheduled status, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("scheduled_statuses_get_status_success.json")
+        val scheduledStatusMethods = ScheduledStatusMethods(client)
+        val scheduledStatusId = "3221"
+
+        val scheduledStatus: ScheduledStatus = scheduledStatusMethods
+            .getScheduledStatus(withId = scheduledStatusId)
+            .execute()
+
+        with(scheduledStatus) {
+            id shouldBeEqualTo "3221"
+            scheduledAt shouldBeEqualTo ExactTime(Instant.parse("2019-12-05T12:33:01.000Z"))
+
+            with(params) {
+                poll.shouldBeNull()
+                text shouldBeEqualTo "test content"
+                mediaIds.shouldBeNull()
+                sensitive.shouldBeNull()
+                visibility.shouldBeNull()
+                idempotency.shouldBeNull()
+                scheduledAt shouldBeEqualTo InvalidPrecisionDateTime.Unavailable
+                spoilerText.shouldBeNull()
+                applicationId shouldBeEqualTo 596_551
+                inReplyToId.shouldBeNull()
+            }
+
+            mediaAttachments.shouldBeEmpty()
+        }
+        verify {
+            client.get(
+                path = "api/v1/scheduled_statuses/3221",
+                query = null
+            )
+        }
+    }
+
+    @Test
+    fun `Given a client that would return success, when updating a status less than 5min ahead, then throw Exception`() {
+        val client = MockClient.mock("scheduled_statuses_update_success.json")
+        val scheduledStatusMethods = ScheduledStatusMethods(client)
+        val scheduledDate: Instant = Instant.now().plus(Duration.ofMinutes(2))
+
+        invoking {
+            scheduledStatusMethods.updatePublishingDate(
+                ofId = "12345",
+                newPublishingDate = scheduledDate
+            ).execute()
+        }
+            .shouldThrow(IllegalArgumentException::class)
+            .withMessage("New publishing date must lie ahead at least 5 minutes")
+
+        verify { client wasNot Called }
+    }
+
+    @Test
+    fun `Given a client returning success, when updating a status more than 5min ahead, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("scheduled_statuses_update_success.json")
+        val scheduledStatusMethods = ScheduledStatusMethods(client)
+        val scheduledDate: Instant = Instant.now().plus(Duration.ofMinutes(42))
+
+        val scheduledStatus: ScheduledStatus = scheduledStatusMethods.updatePublishingDate(
+            ofId = "3221",
+            newPublishingDate = scheduledDate
+        ).execute()
+
+        with(scheduledStatus) {
+            id shouldBeEqualTo "3221"
+            scheduledAt shouldBeEqualTo ExactTime(Instant.parse("2019-12-05T13:33:01.000Z"))
+
+            with(params) {
+                poll.shouldBeNull()
+                text shouldBeEqualTo "test content"
+                mediaIds.shouldBeNull()
+                sensitive.shouldBeNull()
+                visibility.shouldBeNull()
+                idempotency.shouldBeNull()
+                scheduledAt shouldBeEqualTo InvalidPrecisionDateTime.Unavailable
+                spoilerText.shouldBeNull()
+                applicationId shouldBeEqualTo 596_551
+                inReplyToId.shouldBeNull()
+            }
+
+            mediaAttachments.shouldBeEmpty()
+        }
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.put(
+                path = "api/v1/scheduled_statuses/3221",
+                body = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "scheduled_at=${scheduledDate.toString().urlEncode()}"
+        }
+    }
+
+    @Test
+    fun `Given a client that would return success, when cancelling a scheduled status, then call correct endpoint`() {
+        val client = MockClient.mockClearText("")
+        val scheduledStatusMethods = ScheduledStatusMethods(client)
+
+        invoking {
+            scheduledStatusMethods.cancelScheduledStatus(withId = "12345")
+        }.shouldNotThrow(AnyException)
+
+        verify {
+            client.performAction(
+                endpoint = "api/v1/scheduled_statuses/12345",
+                method = Method.DELETE,
+                parameters = null
+            )
+        }
+    }
+}