Repository: andregasser/bigbone (kotlin)

Issue:
Implement Admin Canonical Email Blocks API
Mastodon's Admin Canonical Email Blocks API allows an admin to block certain email addresses by their hash. The goal of this task is to implement the following endpoints of this API in BigBone:

- List all canonical email blocks
- Show a single canonical email block
- Test
- Block a canonical email
- Delete a canonical email block

The Admin Canonical Email Blocks API as it is documented by Mastodon can be found here:
https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/

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 to get all or one, delete, and add canonical email block(s). Also adds the “test” method which I’ve renamed a more expressive `canonicalizeAndHashEmailAddress`.

Closes #331.

# Type of Change

- New feature
- Documentation

# Breaking Changes

- None

# How Has This Been Tested?

I’ve added new unit tests for all methods and variants.

# 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/RxAdminCanonicalEmailBlockMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminCanonicalEmailBlockMethods.kt
new file mode 100644
index 000000000..b3e7b1548
--- /dev/null
+++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminCanonicalEmailBlockMethods.kt
@@ -0,0 +1,81 @@
+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.admin.AdminCanonicalEmailBlock
+import social.bigbone.api.entity.admin.BlockCanonicalEmailVariant
+import social.bigbone.api.method.admin.AdminCanonicalEmailBlockMethods
+
+/**
+ * Reactive implementation of [AdminCanonicalEmailBlockMethods].
+ *
+ * Block certain email addresses by their hash.
+ *
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/">Mastodon admin/canonical_email_blocks API methods</a>
+ */
+class RxAdminCanonicalEmailBlockMethods(client: MastodonClient) {
+
+    private val adminCanonicalEmailBlockMethods = AdminCanonicalEmailBlockMethods(client)
+
+    /**
+     * List all canonical email blocks.
+     *
+     * @param range optional Range for the pageable return value
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#get">
+     *     Mastodon API documentation: admin/canonical_email_blocks/#get</a>
+     */
+    @JvmOverloads
+    fun getAllCanonicalEmailBlocks(range: Range = Range()): Single<Pageable<AdminCanonicalEmailBlock>> =
+        Single.fromCallable { adminCanonicalEmailBlockMethods.getAllCanonicalEmailBlocks(range).execute() }
+
+    /**
+     * Show a single canonical email block.
+     *
+     * @param id The ID of the Admin::CanonicalEmailBlock in the database.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#get-one">
+     *     Mastodon API documentation: admin/canonical_email_blocks/#get-one</a>
+     */
+    fun getCanonicalEmailBlock(id: String): Single<AdminCanonicalEmailBlock> = Single.fromCallable {
+        adminCanonicalEmailBlockMethods.getCanonicalEmailBlock(id).execute()
+    }
+
+    /**
+     * Block a canonical email.
+     *
+     * @param variant One of [BlockCanonicalEmailVariant]s to define how to block a canonical email.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#create">
+     *     Mastodon API documentation: admin/canonical_email_blocks/#create</a>
+     */
+    fun blockCanonicalEmail(variant: BlockCanonicalEmailVariant): Single<AdminCanonicalEmailBlock> =
+        Single.fromCallable { adminCanonicalEmailBlockMethods.blockCanonicalEmail(variant).execute() }
+
+    /**
+     * Delete a canonical email block.
+     *
+     * @param id The ID of the Admin::CanonicalEmailBlock in the database.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#delete">
+     *     Mastodon API documentation: admin/canonical_email_blocks/#delete</a>
+     */
+    fun removeCanonicalEmailBlock(id: String) = Completable.fromAction {
+        adminCanonicalEmailBlockMethods.removeCanonicalEmailBlock(id)
+    }
+
+    /**
+     * Canonicalize and hash an email address.
+     *
+     * @param emailAddress The email to canonicalize and hash.
+     * @return All matching canonical email blocks.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#test">
+     *     Mastodon API documentation admin/canonical_email_blocks/#test</a>
+     */
+    fun canonicalizeAndHashEmailAddress(emailAddress: String): Single<List<AdminCanonicalEmailBlock>> =
+        Single.fromCallable { adminCanonicalEmailBlockMethods.canonicalizeAndHashEmailAddress(emailAddress).execute() }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
index 1c3e6b08e..3c7c8c1ec 100644
--- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
@@ -65,6 +65,7 @@ import social.bigbone.api.method.SuggestionMethods
 import social.bigbone.api.method.TagMethods
 import social.bigbone.api.method.TimelineMethods
 import social.bigbone.api.method.TrendMethods
+import social.bigbone.api.method.admin.AdminCanonicalEmailBlockMethods
 import social.bigbone.api.method.admin.AdminDimensionMethods
 import social.bigbone.api.method.admin.AdminDomainBlockMethods
 import social.bigbone.api.method.admin.AdminEmailDomainBlockMethods
@@ -107,6 +108,13 @@ private constructor(
     @get:JvmName("accounts")
     val accounts: AccountMethods by lazy { AccountMethods(this) }
 
+    /**
+     * Access API methods under the "admin/canonical_email_blocks" endpoint.
+     */
+    @Suppress("unused") // public API
+    @get:JvmName("adminCanonicalEmailBlocks")
+    val adminCanonicalEmailBlocks: AdminCanonicalEmailBlockMethods by lazy { AdminCanonicalEmailBlockMethods(this) }
+
     /**
      * Access API methods under the "admin/dimensions" endpoint.
      */
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCanonicalEmailBlock.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCanonicalEmailBlock.kt
new file mode 100644
index 000000000..f8a683ca0
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminCanonicalEmailBlock.kt
@@ -0,0 +1,63 @@
+package social.bigbone.api.entity.admin
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import social.bigbone.Parameters
+import social.bigbone.api.method.admin.AdminCanonicalEmailBlockMethods
+
+/**
+ * Block certain email addresses by their hash.
+ *
+ * @see <a href="https://docs.joinmastodon.org/entities/Admin_CanonicalEmailBlock/">Mastodon documentation Admin::CanonicalEmailBlock</a>
+ */
+@Serializable
+data class AdminCanonicalEmailBlock(
+    /**
+     * The ID of the email block in the database.
+     * String cast from Integer, but not guaranteed to be a number.
+     */
+    @SerialName("id")
+    val id: String,
+
+    /**
+     * The SHA256 hash of the canonical email address.
+     */
+    @SerialName("canonical_email_hash")
+    val canonicalEmailHash: String
+)
+
+/**
+ * Means of blocking a canonical email address.
+ *
+ * When blocking a canonical email address via [AdminCanonicalEmailBlockMethods.blockCanonicalEmail], the Mastodon API offers 2 variants:
+ *
+ *   1. By email address
+ *   2. By canonical email hash
+ *
+ * If either one is supplied, the other one is ignored. To ensure that this fact is understood, this sealed class
+ * is used to wrap those two options, disallowing consumers to ever use both at the same time.
+ */
+sealed class BlockCanonicalEmailVariant(val apiKey: String, val value: String) {
+    /**
+     * Used to block a canonical email via an email address.
+     *
+     * @property email The email address to block
+     */
+    data class ByEmail(val email: String) : BlockCanonicalEmailVariant(
+        apiKey = "email",
+        value = email
+    )
+
+    /**
+     * Used to block a canonical email via a hash.
+     *
+     * @property canonicalEmailHash The email hash to block
+     */
+    data class ByHash(val canonicalEmailHash: String) : BlockCanonicalEmailVariant(
+        apiKey = "canonical_email_hash",
+        value = canonicalEmailHash
+    )
+
+    internal fun appendToParameters(parameters: Parameters = Parameters()): Parameters =
+        parameters.apply { append(apiKey, value) }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminCanonicalEmailBlockMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminCanonicalEmailBlockMethods.kt
new file mode 100644
index 000000000..557b31e37
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminCanonicalEmailBlockMethods.kt
@@ -0,0 +1,99 @@
+package social.bigbone.api.method.admin
+
+import social.bigbone.MastodonClient
+import social.bigbone.MastodonRequest
+import social.bigbone.Parameters
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.admin.AdminCanonicalEmailBlock
+import social.bigbone.api.entity.admin.BlockCanonicalEmailVariant
+
+/**
+ * Block certain email addresses by their hash.
+ *
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/">Mastodon admin/canonical_email_blocks API methods</a>
+ */
+class AdminCanonicalEmailBlockMethods(private val client: MastodonClient) {
+
+    private val adminCanonicalEmailBlockEndpoint = "api/v1/admin/canonical_email_blocks"
+
+    /**
+     * List all canonical email blocks.
+     *
+     * @param range optional Range for the pageable return value
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#get">
+     *     Mastodon API documentation: admin/canonical_email_blocks/#get</a>
+     */
+    @JvmOverloads
+    fun getAllCanonicalEmailBlocks(range: Range = Range()): MastodonRequest<Pageable<AdminCanonicalEmailBlock>> {
+        return client.getPageableMastodonRequest(
+            endpoint = adminCanonicalEmailBlockEndpoint,
+            method = MastodonClient.Method.GET,
+            parameters = range.toParameters()
+        )
+    }
+
+    /**
+     * Show a single canonical email block.
+     *
+     * @param id The ID of the Admin::CanonicalEmailBlock in the database.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#get-one">
+     *     Mastodon API documentation: admin/canonical_email_blocks/#get-one</a>
+     */
+    fun getCanonicalEmailBlock(id: String): MastodonRequest<AdminCanonicalEmailBlock> {
+        return client.getMastodonRequest(
+            endpoint = "$adminCanonicalEmailBlockEndpoint/$id",
+            method = MastodonClient.Method.GET
+        )
+    }
+
+    /**
+     * Block a canonical email.
+     *
+     * @param variant One of [BlockCanonicalEmailVariant]s to define how to block a canonical email.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#create">
+     *     Mastodon API documentation: admin/canonical_email_blocks/#create</a>
+     */
+    fun blockCanonicalEmail(variant: BlockCanonicalEmailVariant): MastodonRequest<AdminCanonicalEmailBlock> {
+        return client.getMastodonRequest(
+            endpoint = adminCanonicalEmailBlockEndpoint,
+            method = MastodonClient.Method.POST,
+            parameters = variant.appendToParameters()
+        )
+    }
+
+    /**
+     * Delete a canonical email block.
+     *
+     * @param id The ID of the Admin::CanonicalEmailBlock in the database.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#delete">
+     *     Mastodon API documentation: admin/canonical_email_blocks/#delete</a>
+     */
+    fun removeCanonicalEmailBlock(id: String) {
+        client.performAction(
+            endpoint = "$adminCanonicalEmailBlockEndpoint/$id",
+            method = MastodonClient.Method.DELETE
+        )
+    }
+
+    /**
+     * Canonicalize and hash an email address.
+     *
+     * @param emailAddress The email to canonicalize and hash.
+     * @return All matching canonical email blocks.
+     *
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/#test">
+     *     Mastodon API documentation admin/canonical_email_blocks/#test</a>
+     */
+    fun canonicalizeAndHashEmailAddress(emailAddress: String): MastodonRequest<List<AdminCanonicalEmailBlock>> {
+        return client.getMastodonRequestForList(
+            endpoint = "$adminCanonicalEmailBlockEndpoint/test",
+            method = MastodonClient.Method.POST,
+            parameters = Parameters().append("email", emailAddress)
+        )
+    }
+}
diff --git a/docs/api-coverage/admin/canonical-email-blocks.md b/docs/api-coverage/admin/canonical-email-blocks.md
index eeb855235..a5950ac33 100644
--- a/docs/api-coverage/admin/canonical-email-blocks.md
+++ b/docs/api-coverage/admin/canonical-email-blocks.md
@@ -10,10 +10,10 @@ nav_order: 5
 
 <a href="https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/" target="_blank">https://docs.joinmastodon.org/methods/admin/canonical_email_blocks/</a>
 
-| Method                                            | Description                         | Status | Comments | 
-|---------------------------------------------------|-------------------------------------|--------|----------|
-| `GET /api/v1/admin/canonical_email_blocks`        | List all canonical email blocks     | 🔴     |          |
-| `GET /api/v1/admin/canonical_email_blocks/:id`    | Show a single canonical email block | 🔴     |          |
-| `POST /api/v1/admin/canonical_email_blocks/test`  | Test                                | 🔴     |          |
-| `POST /api/v1/admin/canonical_email_blocks`       | Block a canonical email             | 🔴     |          |
-| `DELETE /api/v1/admin/canonical_email_blocks/:id` | Delete a canonical email block      | 🔴     |          |
+| Method                                            | Description                         | Status                          | Comments        | 
+|---------------------------------------------------|-------------------------------------|---------------------------------|-----------------|
+| `GET /api/v1/admin/canonical_email_blocks`        | List all canonical email blocks     | <img src="/assets/green16.png"> | Fully supported |
+| `GET /api/v1/admin/canonical_email_blocks/:id`    | Show a single canonical email block | <img src="/assets/green16.png"> | Fully supported |
+| `POST /api/v1/admin/canonical_email_blocks/test`  | Test                                | <img src="/assets/green16.png"> | Fully supported |
+| `POST /api/v1/admin/canonical_email_blocks`       | Block a canonical email             | <img src="/assets/green16.png"> | Fully supported |
+| `DELETE /api/v1/admin/canonical_email_blocks/:id` | Delete a canonical email block      | <img src="/assets/green16.png"> | Fully supported |


Test patch:
diff --git a/bigbone/src/test/assets/admin_canonical_email_blocks_all_blocked_success.json b/bigbone/src/test/assets/admin_canonical_email_blocks_all_blocked_success.json
new file mode 100644
index 000000000..489cb1b60
--- /dev/null
+++ b/bigbone/src/test/assets/admin_canonical_email_blocks_all_blocked_success.json
@@ -0,0 +1,6 @@
+[
+  {
+    "id": "1",
+    "canonical_email_hash": "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+  }
+]
diff --git a/bigbone/src/test/assets/admin_canonical_email_blocks_block_success.json b/bigbone/src/test/assets/admin_canonical_email_blocks_block_success.json
new file mode 100644
index 000000000..3d9555ce9
--- /dev/null
+++ b/bigbone/src/test/assets/admin_canonical_email_blocks_block_success.json
@@ -0,0 +1,4 @@
+{
+  "id": "1",
+  "canonical_email_hash": "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+}
diff --git a/bigbone/src/test/assets/admin_canonical_email_blocks_delete_success.json b/bigbone/src/test/assets/admin_canonical_email_blocks_delete_success.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/bigbone/src/test/assets/admin_canonical_email_blocks_delete_success.json
@@ -0,0 +1,1 @@
+{}
diff --git a/bigbone/src/test/assets/admin_canonical_email_blocks_single_success.json b/bigbone/src/test/assets/admin_canonical_email_blocks_single_success.json
new file mode 100644
index 000000000..3d9555ce9
--- /dev/null
+++ b/bigbone/src/test/assets/admin_canonical_email_blocks_single_success.json
@@ -0,0 +1,4 @@
+{
+  "id": "1",
+  "canonical_email_hash": "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+}
diff --git a/bigbone/src/test/assets/admin_canonical_email_blocks_test_success.json b/bigbone/src/test/assets/admin_canonical_email_blocks_test_success.json
new file mode 100644
index 000000000..489cb1b60
--- /dev/null
+++ b/bigbone/src/test/assets/admin_canonical_email_blocks_test_success.json
@@ -0,0 +1,6 @@
+[
+  {
+    "id": "1",
+    "canonical_email_hash": "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+  }
+]
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminCanonicalEmailBlockMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminCanonicalEmailBlockMethodsTest.kt
new file mode 100644
index 000000000..63a0e3f55
--- /dev/null
+++ b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminCanonicalEmailBlockMethodsTest.kt
@@ -0,0 +1,192 @@
+package social.bigbone.api.method.admin
+
+import io.mockk.slot
+import io.mockk.verify
+import org.amshove.kluent.shouldBeEmpty
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldHaveSize
+import org.junit.jupiter.api.Test
+import social.bigbone.MastodonClient
+import social.bigbone.Parameters
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.admin.AdminCanonicalEmailBlock
+import social.bigbone.api.entity.admin.BlockCanonicalEmailVariant
+import social.bigbone.testtool.MockClient
+import social.bigbone.testtool.TestUtil.urlEncode
+
+class AdminCanonicalEmailBlockMethodsTest {
+
+    @Test
+    fun `Given client returning success, when getting all canonical email blocks without Range, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("admin_canonical_email_blocks_all_blocked_success.json")
+        val adminCanonicalEmailBlockMethods = AdminCanonicalEmailBlockMethods(client)
+
+        val canonicalEmailBlocks: Pageable<AdminCanonicalEmailBlock> = adminCanonicalEmailBlockMethods
+            .getAllCanonicalEmailBlocks()
+            .execute()
+
+        with(canonicalEmailBlocks.part) {
+            shouldHaveSize(1)
+
+            with(get(0)) {
+                id shouldBeEqualTo "1"
+                canonicalEmailHash shouldBeEqualTo "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+            }
+        }
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.get(
+                path = "api/v1/admin/canonical_email_blocks",
+                query = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery().shouldBeEmpty()
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when getting all canonical email blocks with Range, then use correct parameters`() {
+        val client = MockClient.mock("admin_canonical_email_blocks_all_blocked_success.json")
+        val adminCanonicalEmailBlockMethods = AdminCanonicalEmailBlockMethods(client)
+        val range = Range(limit = 5, sinceId = "1234")
+
+        adminCanonicalEmailBlockMethods.getAllCanonicalEmailBlocks(range).execute()
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.get(
+                path = "api/v1/admin/canonical_email_blocks",
+                query = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "since_id=${range.sinceId}&limit=${range.limit}"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when getting single canonical email block, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("admin_canonical_email_blocks_single_success.json")
+        val adminCanonicalEmailBlockMethods = AdminCanonicalEmailBlockMethods(client)
+        val requestedId = "1"
+
+        val canonicalEmailBlock: AdminCanonicalEmailBlock = adminCanonicalEmailBlockMethods
+            .getCanonicalEmailBlock(requestedId)
+            .execute()
+
+        with(canonicalEmailBlock) {
+            id shouldBeEqualTo "1"
+            canonicalEmailHash shouldBeEqualTo "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+        }
+        verify {
+            client.get(
+                path = "api/v1/admin/canonical_email_blocks/$requestedId",
+                query = null
+            )
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when blocking a canonical email by email address, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("admin_canonical_email_blocks_block_success.json")
+        val adminCanonicalEmailBlockMethods = AdminCanonicalEmailBlockMethods(client)
+        val requestedEmailAddress = BlockCanonicalEmailVariant.ByEmail(email = "foo")
+
+        val canonicalEmailBlock: AdminCanonicalEmailBlock = adminCanonicalEmailBlockMethods
+            .blockCanonicalEmail(requestedEmailAddress)
+            .execute()
+
+        with(canonicalEmailBlock) {
+            id shouldBeEqualTo "1"
+            canonicalEmailHash shouldBeEqualTo "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+        }
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.post(
+                path = "api/v1/admin/canonical_email_blocks",
+                body = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "email=${requestedEmailAddress.email}"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when blocking a canonical email by email hash, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("admin_canonical_email_blocks_block_success.json")
+        val adminCanonicalEmailBlockMethods = AdminCanonicalEmailBlockMethods(client)
+        val requestedEmailHash = BlockCanonicalEmailVariant.ByHash(
+            canonicalEmailHash = "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+        )
+
+        val canonicalEmailBlock: AdminCanonicalEmailBlock = adminCanonicalEmailBlockMethods
+            .blockCanonicalEmail(requestedEmailHash)
+            .execute()
+
+        with(canonicalEmailBlock) {
+            id shouldBeEqualTo "1"
+            canonicalEmailHash shouldBeEqualTo "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+        }
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.post(
+                path = "api/v1/admin/canonical_email_blocks",
+                body = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "canonical_email_hash=${requestedEmailHash.canonicalEmailHash}"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when removing a canonical email block, then call correct endpoint`() {
+        val client = MockClient.mock("admin_canonical_email_blocks_delete_success.json")
+        val adminCanonicalEmailBlockMethods = AdminCanonicalEmailBlockMethods(client)
+        val requestedId = "foo"
+
+        adminCanonicalEmailBlockMethods.removeCanonicalEmailBlock(requestedId)
+
+        verify {
+            client.performAction(
+                endpoint = "api/v1/admin/canonical_email_blocks/$requestedId",
+                method = MastodonClient.Method.DELETE,
+                parameters = null
+            )
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when canonicalising an email address, then call correct endpoint and parse response`() {
+        val client = MockClient.mock("admin_canonical_email_blocks_test_success.json")
+        val adminCanonicalEmailBlockMethods = AdminCanonicalEmailBlockMethods(client)
+        val requestedEmailAddress = "foo"
+
+        val canonicalizedEmailAddress: List<AdminCanonicalEmailBlock> = adminCanonicalEmailBlockMethods
+            .canonicalizeAndHashEmailAddress(requestedEmailAddress)
+            .execute()
+
+        with(canonicalizedEmailAddress) {
+            shouldHaveSize(1)
+
+            with(get(0)) {
+                id shouldBeEqualTo "1"
+                canonicalEmailHash shouldBeEqualTo "b344e55d11b3fc25d0d53194e0475838bf17e9be67ce3e6469956222d9a34f9c"
+            }
+        }
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.post(
+                path = "api/v1/admin/canonical_email_blocks/test",
+                body = capture(parametersCapturingSlot),
+                addIdempotencyKey = false
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "email=${requestedEmailAddress.urlEncode()}"
+        }
+    }
+}