From 6ba25d11973e56008e5d674313421197ff418d6d Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 23:19:16 +0400
Subject: [PATCH 1/2] Add Attachment schema

---
 .../web/api_spec/schemas/attachment.ex        | 68 +++++++++++++++++++
 .../web/api_spec/schemas/scheduled_status.ex  | 53 +++++++++++++++
 lib/pleroma/web/api_spec/schemas/status.ex    | 18 +----
 3 files changed, 123 insertions(+), 16 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/schemas/attachment.ex
 create mode 100644 lib/pleroma/web/api_spec/schemas/scheduled_status.ex

diff --git a/lib/pleroma/web/api_spec/schemas/attachment.ex b/lib/pleroma/web/api_spec/schemas/attachment.ex
new file mode 100644
index 000000000..c146c416e
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/attachment.ex
@@ -0,0 +1,68 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.Attachment do
+  alias OpenApiSpex.Schema
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "Attachment",
+    description: "Represents a file or media attachment that can be added to a status.",
+    type: :object,
+    requried: [:id, :url, :preview_url],
+    properties: %{
+      id: %Schema{type: :string},
+      url: %Schema{
+        type: :string,
+        format: :uri,
+        description: "The location of the original full-size attachment"
+      },
+      remote_url: %Schema{
+        type: :string,
+        format: :uri,
+        description:
+          "The location of the full-size original attachment on the remote website. String (URL), or null if the attachment is local",
+        nullable: true
+      },
+      preview_url: %Schema{
+        type: :string,
+        format: :uri,
+        description: "The location of a scaled-down preview of the attachment"
+      },
+      text_url: %Schema{
+        type: :string,
+        format: :uri,
+        description: "A shorter URL for the attachment"
+      },
+      description: %Schema{
+        type: :string,
+        nullable: true,
+        description:
+          "Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load"
+      },
+      type: %Schema{
+        type: :string,
+        enum: ["image", "video", "audio", "unknown"],
+        description: "The type of the attachment"
+      },
+      pleroma: %Schema{
+        type: :object,
+        properties: %{
+          mime_type: %Schema{type: :string, description: "mime type of the attachment"}
+        }
+      }
+    },
+    example: %{
+      id: "1638338801",
+      type: "image",
+      url: "someurl",
+      remote_url: "someurl",
+      preview_url: "someurl",
+      text_url: "someurl",
+      description: nil,
+      pleroma: %{mime_type: "image/png"}
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
new file mode 100644
index 000000000..f0bc4ee3c
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
@@ -0,0 +1,53 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
+  alias Pleroma.Web.ApiSpec.Schemas.Poll
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "ScheduledStatus",
+    description: "Represents a status that will be published at a future scheduled date.",
+    type: :object,
+    required: [:id, :scheduled_at, :params],
+    properties: %{
+      id: %Schema{type: :string},
+      scheduled_at: %Schema{type: :string, format: :"date-time"},
+      media_attachments: %Schema{type: :array, format: :"date-time"},
+      params: %Schema{
+        type: :object,
+        required: [:text, :visibility],
+        properties: %{
+          text: %Schema{type: :string, nullable: true},
+          media_ids: %Schema{type: :array, nullable: true, items: %Schema{type: :string}},
+          sensitive: %Schema{type: :boolean, nullable: true},
+          spoiler_text: %Schema{type: :string, nullable: true},
+          visibility: %Schema{type: VisibilityScope, nullable: true},
+          scheduled_at: %Schema{type: :string, format: :"date-time", nullable: true},
+          poll: %Schema{type: Poll, nullable: true},
+          in_reply_to_id: %Schema{type: :string, nullable: true}
+        }
+      }
+    },
+    example: %{
+      id: "3221",
+      scheduled_at: "2019-12-05T12:33:01.000Z",
+      params: %{
+        text: "test content",
+        media_ids: nil,
+        sensitive: nil,
+        spoiler_text: nil,
+        visibility: nil,
+        scheduled_at: nil,
+        poll: nil,
+        idempotency: nil,
+        in_reply_to_id: nil
+      },
+      media_attachments: []
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex
index aef0588d4..d44636a48 100644
--- a/lib/pleroma/web/api_spec/schemas/status.ex
+++ b/lib/pleroma/web/api_spec/schemas/status.ex
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.ApiSpec.Schemas.Status do
   alias OpenApiSpex.Schema
   alias Pleroma.Web.ApiSpec.Schemas.Account
+  alias Pleroma.Web.ApiSpec.Schemas.Attachment
   alias Pleroma.Web.ApiSpec.Schemas.Emoji
   alias Pleroma.Web.ApiSpec.Schemas.FlakeID
   alias Pleroma.Web.ApiSpec.Schemas.Poll
@@ -50,22 +51,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
       language: %Schema{type: :string, nullable: true},
       media_attachments: %Schema{
         type: :array,
-        items: %Schema{
-          type: :object,
-          properties: %{
-            id: %Schema{type: :string},
-            url: %Schema{type: :string, format: :uri},
-            remote_url: %Schema{type: :string, format: :uri},
-            preview_url: %Schema{type: :string, format: :uri},
-            text_url: %Schema{type: :string, format: :uri},
-            description: %Schema{type: :string},
-            type: %Schema{type: :string, enum: ["image", "video", "audio", "unknown"]},
-            pleroma: %Schema{
-              type: :object,
-              properties: %{mime_type: %Schema{type: :string}}
-            }
-          }
-        }
+        items: Attachment
       },
       mentions: %Schema{
         type: :array,

From 332e016bcdbda5dca90d916bc62a9c67544b5323 Mon Sep 17 00:00:00 2001
From: Egor Kislitsyn <egor@kislitsyn.com>
Date: Tue, 5 May 2020 23:42:18 +0400
Subject: [PATCH 2/2] Add OpenAPI spec for ScheduledActivityController

---
 .../scheduled_activity_operation.ex           | 96 +++++++++++++++++++
 .../web/api_spec/schemas/scheduled_status.ex  |  7 +-
 .../scheduled_activity_controller.ex          | 12 ++-
 test/support/helpers.ex                       |  8 +-
 .../scheduled_activity_controller_test.exs    | 34 ++++---
 .../mastodon_api/views/status_view_test.exs   |  8 +-
 6 files changed, 144 insertions(+), 21 deletions(-)
 create mode 100644 lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex

diff --git a/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex
new file mode 100644
index 000000000..fe675a923
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex
@@ -0,0 +1,96 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.ScheduledActivityOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Scheduled Statuses"],
+      summary: "View scheduled statuses",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: pagination_params(),
+      operationId: "ScheduledActivity.index",
+      responses: %{
+        200 =>
+          Operation.response("Array of ScheduledStatus", "application/json", %Schema{
+            type: :array,
+            items: ScheduledStatus
+          })
+      }
+    }
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Scheduled Statuses"],
+      summary: "View a single scheduled status",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [id_param()],
+      operationId: "ScheduledActivity.show",
+      responses: %{
+        200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Scheduled Statuses"],
+      summary: "Schedule a status",
+      operationId: "ScheduledActivity.update",
+      security: [%{"oAuth" => ["write:statuses"]}],
+      parameters: [id_param()],
+      requestBody:
+        request_body("Parameters", %Schema{
+          type: :object,
+          properties: %{
+            scheduled_at: %Schema{
+              type: :string,
+              format: :"date-time",
+              description:
+                "ISO 8601 Datetime at which the status will be published. Must be at least 5 minutes into the future."
+            }
+          }
+        }),
+      responses: %{
+        200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["Scheduled Statuses"],
+      summary: "Cancel a scheduled status",
+      security: [%{"oAuth" => ["write:statuses"]}],
+      parameters: [id_param()],
+      operationId: "ScheduledActivity.delete",
+      responses: %{
+        200 => Operation.response("Empty object", "application/json", %Schema{type: :object}),
+        404 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(:id, :path, FlakeID, "Poll ID",
+      example: "123",
+      required: true
+    )
+  end
+end
diff --git a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
index f0bc4ee3c..0520d0848 100644
--- a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
+++ b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex
@@ -4,8 +4,9 @@
 
 defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do
   alias OpenApiSpex.Schema
-  alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
+  alias Pleroma.Web.ApiSpec.Schemas.Attachment
   alias Pleroma.Web.ApiSpec.Schemas.Poll
+  alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
 
   require OpenApiSpex
 
@@ -17,7 +18,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do
     properties: %{
       id: %Schema{type: :string},
       scheduled_at: %Schema{type: :string, format: :"date-time"},
-      media_attachments: %Schema{type: :array, format: :"date-time"},
+      media_attachments: %Schema{type: :array, items: Attachment},
       params: %Schema{
         type: :object,
         required: [:text, :visibility],
@@ -47,7 +48,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do
         idempotency: nil,
         in_reply_to_id: nil
       },
-      media_attachments: []
+      media_attachments: [Attachment.schema().example]
     }
   })
 end
diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex
index 899b78873..1719c67ea 100644
--- a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex
@@ -11,17 +11,21 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do
   alias Pleroma.ScheduledActivity
   alias Pleroma.Web.MastodonAPI.MastodonAPI
 
-  plug(:assign_scheduled_activity when action != :index)
-
   @oauth_read_actions [:show, :index]
 
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
   plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions)
   plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions)
+  plug(:assign_scheduled_activity when action != :index)
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ScheduledActivityOperation
+
   @doc "GET /api/v1/scheduled_statuses"
   def index(%{assigns: %{user: user}} = conn, params) do
+    params = Map.new(params, fn {key, value} -> {to_string(key), value} end)
+
     with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do
       conn
       |> add_link_headers(scheduled_activities)
@@ -35,7 +39,7 @@ def show(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params)
   end
 
   @doc "PUT /api/v1/scheduled_statuses/:id"
-  def update(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, params) do
+  def update(%{assigns: %{scheduled_activity: scheduled_activity}, body_params: params} = conn, _) do
     with {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do
       render(conn, "show.json", scheduled_activity: scheduled_activity)
     end
@@ -48,7 +52,7 @@ def delete(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params
     end
   end
 
-  defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do
+  defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do
     case ScheduledActivity.get(user, id) do
       %ScheduledActivity{} = activity -> assign(conn, :scheduled_activity, activity)
       nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt()
diff --git a/test/support/helpers.ex b/test/support/helpers.ex
index e68e9bfd2..26281b45e 100644
--- a/test/support/helpers.ex
+++ b/test/support/helpers.ex
@@ -40,12 +40,18 @@ defmacro __using__(_opts) do
           clear_config: 2
         ]
 
-      def to_datetime(naive_datetime) do
+      def to_datetime(%NaiveDateTime{} = naive_datetime) do
         naive_datetime
         |> DateTime.from_naive!("Etc/UTC")
         |> DateTime.truncate(:second)
       end
 
+      def to_datetime(datetime) when is_binary(datetime) do
+        datetime
+        |> NaiveDateTime.from_iso8601!()
+        |> to_datetime()
+      end
+
       def collect_ids(collection) do
         collection
         |> Enum.map(& &1.id)
diff --git a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
index f86274d57..1ff871c89 100644
--- a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
+++ b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs
@@ -24,19 +24,19 @@ test "shows scheduled activities" do
     # min_id
     conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}")
 
-    result = json_response(conn_res, 200)
+    result = json_response_and_validate_schema(conn_res, 200)
     assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result
 
     # since_id
     conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}")
 
-    result = json_response(conn_res, 200)
+    result = json_response_and_validate_schema(conn_res, 200)
     assert [%{"id" => ^scheduled_activity_id4}, %{"id" => ^scheduled_activity_id3}] = result
 
     # max_id
     conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}")
 
-    result = json_response(conn_res, 200)
+    result = json_response_and_validate_schema(conn_res, 200)
     assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result
   end
 
@@ -46,12 +46,12 @@ test "shows a scheduled activity" do
 
     res_conn = get(conn, "/api/v1/scheduled_statuses/#{scheduled_activity.id}")
 
-    assert %{"id" => scheduled_activity_id} = json_response(res_conn, 200)
+    assert %{"id" => scheduled_activity_id} = json_response_and_validate_schema(res_conn, 200)
     assert scheduled_activity_id == scheduled_activity.id |> to_string()
 
     res_conn = get(conn, "/api/v1/scheduled_statuses/404")
 
-    assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
   end
 
   test "updates a scheduled activity" do
@@ -74,22 +74,32 @@ test "updates a scheduled activity" do
     assert job.args == %{"activity_id" => scheduled_activity.id}
     assert DateTime.truncate(job.scheduled_at, :second) == to_datetime(scheduled_at)
 
-    new_scheduled_at = Timex.shift(NaiveDateTime.utc_now(), minutes: 120)
+    new_scheduled_at =
+      NaiveDateTime.utc_now()
+      |> Timex.shift(minutes: 120)
+      |> Timex.format!("%Y-%m-%dT%H:%M:%S.%fZ", :strftime)
 
     res_conn =
-      put(conn, "/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{
         scheduled_at: new_scheduled_at
       })
 
-    assert %{"scheduled_at" => expected_scheduled_at} = json_response(res_conn, 200)
+    assert %{"scheduled_at" => expected_scheduled_at} =
+             json_response_and_validate_schema(res_conn, 200)
+
     assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(new_scheduled_at)
     job = refresh_record(job)
 
     assert DateTime.truncate(job.scheduled_at, :second) == to_datetime(new_scheduled_at)
 
-    res_conn = put(conn, "/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at})
+    res_conn =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at})
 
-    assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
   end
 
   test "deletes a scheduled activity" do
@@ -115,7 +125,7 @@ test "deletes a scheduled activity" do
       |> assign(:user, user)
       |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
 
-    assert %{} = json_response(res_conn, 200)
+    assert %{} = json_response_and_validate_schema(res_conn, 200)
     refute Repo.get(ScheduledActivity, scheduled_activity.id)
     refute Repo.get(Oban.Job, job.id)
 
@@ -124,6 +134,6 @@ test "deletes a scheduled activity" do
       |> assign(:user, user)
       |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}")
 
-    assert %{"error" => "Record not found"} = json_response(res_conn, 404)
+    assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404)
   end
 end
diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs
index 6791c2fb0..451723e60 100644
--- a/test/web/mastodon_api/views/status_view_test.exs
+++ b/test/web/mastodon_api/views/status_view_test.exs
@@ -402,11 +402,17 @@ test "attachments" do
       pleroma: %{mime_type: "image/png"}
     }
 
+    api_spec = Pleroma.Web.ApiSpec.spec()
+
     assert expected == StatusView.render("attachment.json", %{attachment: object})
+    OpenApiSpex.TestAssertions.assert_schema(expected, "Attachment", api_spec)
 
     # If theres a "id", use that instead of the generated one
     object = Map.put(object, "id", 2)
-    assert %{id: "2"} = StatusView.render("attachment.json", %{attachment: object})
+    result = StatusView.render("attachment.json", %{attachment: object})
+
+    assert %{id: "2"} = result
+    OpenApiSpex.TestAssertions.assert_schema(result, "Attachment", api_spec)
   end
 
   test "put the url advertised in the Activity in to the url attribute" do