From 4040546250333892a1daac0633f9da24af3b8a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Fri, 13 Jan 2023 15:58:03 +0000 Subject: [PATCH 01/34] feat: Adding color tag validation. Adding TagController for API. #256 --- lib/app/tag.ex | 2 + lib/app_web/controllers/api/tag_controller.ex | 92 +++++++++++++++++++ lib/app_web/router.ex | 2 + 3 files changed, 96 insertions(+) create mode 100644 lib/app_web/controllers/api/tag_controller.ex diff --git a/lib/app/tag.ex b/lib/app/tag.ex index d0612ea8..e328e479 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -5,6 +5,7 @@ defmodule App.Tag do alias App.{Item, ItemTag, Repo} alias __MODULE__ + @derive {Jason.Encoder, only: [:text, :person_id, :color]} schema "tags" do field :color, :string field :person_id, :integer @@ -19,6 +20,7 @@ defmodule App.Tag do tag |> cast(attrs, [:person_id, :text, :color]) |> validate_required([:person_id, :text, :color]) + |> validate_format(:color, ~r/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/) |> unique_constraint([:text, :person_id], name: :tags_text_person_id_index) end diff --git a/lib/app_web/controllers/api/tag_controller.ex b/lib/app_web/controllers/api/tag_controller.ex new file mode 100644 index 00000000..63507c50 --- /dev/null +++ b/lib/app_web/controllers/api/tag_controller.ex @@ -0,0 +1,92 @@ +defmodule AppWeb.API.TagController do + use AppWeb, :controller + alias App.Tag + import Ecto.Changeset + + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Tag.get_tag!(id) do + nil -> + errors = %{ + code: 404, + message: "No tag found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + item -> + json(conn, item) + end + + # ID is not an integer + :error -> + errors = %{ + code: 400, + message: "The \'id\' is not an integer." + } + + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + # Attributes to create tag + # Person_id will be changed when auth is added + attrs = %{ + text: Map.get(params, "text"), + person_id: 0, + color: Map.get(params, "color", App.Color.random()) + } + + dbg(attrs) + + case Tag.create_tag(attrs) do + # Successfully creates tag + {:ok, tag} -> + id_tag = Map.take(tag, [:id]) + json(conn, id_tag) + + # Error creating tag + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + + tag = Tag.get_tag!(id) + + case Tag.update_tag(tag, params) do + # Successfully updates tag + {:ok, tag} -> + json(conn, tag) + + # Error creating tag + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request" + } + + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) + Map.put(errors, :errors, changeset_errors) + end +end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index e8bbfaac..07ae515c 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -41,5 +41,7 @@ defmodule AppWeb.Router do resources "/items/:item_id/timers", API.TimerController, only: [:create, :update, :show, :index] + + resources "/tags", API.TagController, only: [:create, :update, :show] end end From 89f8adc9e871876769a6e4ffacb147a1a466d4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Fri, 13 Jan 2023 17:34:44 +0000 Subject: [PATCH 02/34] fix: Adding id validation on update tag. Adding tests. #256 --- lib/app/tag.ex | 4 +- lib/app_web/controllers/api/tag_controller.ex | 39 +++++---- lib/app_web/router.ex | 2 +- .../controllers/api/item_controller_test.exs | 14 +-- .../controllers/api/tag_controller_test.exs | 86 +++++++++++++++++++ .../controllers/api/timer_controller_test.exs | 16 ++-- 6 files changed, 129 insertions(+), 32 deletions(-) create mode 100644 test/app_web/controllers/api/tag_controller_test.exs diff --git a/lib/app/tag.ex b/lib/app/tag.ex index e328e479..946ff149 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -5,7 +5,7 @@ defmodule App.Tag do alias App.{Item, ItemTag, Repo} alias __MODULE__ - @derive {Jason.Encoder, only: [:text, :person_id, :color]} + @derive {Jason.Encoder, only: [:id, :text, :person_id, :color]} schema "tags" do field :color, :string field :person_id, :integer @@ -79,6 +79,8 @@ defmodule App.Tag do def get_tag!(id), do: Repo.get!(Tag, id) + def get_tag(id), do: Repo.get(Tag, id) + def list_person_tags(person_id) do Tag |> where(person_id: ^person_id) diff --git a/lib/app_web/controllers/api/tag_controller.ex b/lib/app_web/controllers/api/tag_controller.ex index 63507c50..242d2d26 100644 --- a/lib/app_web/controllers/api/tag_controller.ex +++ b/lib/app_web/controllers/api/tag_controller.ex @@ -7,7 +7,7 @@ defmodule AppWeb.API.TagController do case Integer.parse(id) do # ID is an integer {id, _float} -> - case Tag.get_tag!(id) do + case Tag.get_tag(id) do nil -> errors = %{ code: 404, @@ -40,8 +40,6 @@ defmodule AppWeb.API.TagController do color: Map.get(params, "color", App.Color.random()) } - dbg(attrs) - case Tag.create_tag(attrs) do # Successfully creates tag {:ok, tag} -> @@ -62,21 +60,32 @@ defmodule AppWeb.API.TagController do def update(conn, params) do id = Map.get(params, "id") - tag = Tag.get_tag!(id) + # Get tag with the ID + case Tag.get_tag(id) do + nil -> + errors = %{ + code: 404, + message: "No tag found with the given \'id\'." + } - case Tag.update_tag(tag, params) do - # Successfully updates tag - {:ok, tag} -> - json(conn, tag) + json(conn |> put_status(404), errors) - # Error creating tag - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) + # If tag is found, try to update it + tag -> + case Tag.update_tag(tag, params) do + # Successfully updates tag + {:ok, tag} -> + json(conn, tag) - json( - conn |> put_status(400), - errors - ) + # Error creating tag + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end end end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index 07ae515c..0dc50373 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -34,7 +34,7 @@ defmodule AppWeb.Router do resources "/tags", TagController, except: [:show] end - scope "/api", AppWeb do + scope "/api", AppWeb, as: :api do pipe_through [:api, :authOptional] resources "/items", API.ItemController, only: [:create, :update, :show] diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/app_web/controllers/api/item_controller_test.exs index 5dcf0eb5..d4e7a68b 100644 --- a/test/app_web/controllers/api/item_controller_test.exs +++ b/test/app_web/controllers/api/item_controller_test.exs @@ -9,7 +9,7 @@ defmodule AppWeb.API.ItemControllerTest do describe "show" do test "specific item", %{conn: conn} do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = get(conn, Routes.item_path(conn, :show, item.id)) + conn = get(conn, Routes.api_item_path(conn, :show, item.id)) assert conn.status == 200 assert json_response(conn, 200)["id"] == item.id @@ -17,20 +17,20 @@ defmodule AppWeb.API.ItemControllerTest do end test "not found item", %{conn: conn} do - conn = get(conn, Routes.item_path(conn, :show, -1)) + conn = get(conn, Routes.api_item_path(conn, :show, -1)) assert conn.status == 404 end test "invalid id (not being an integer)", %{conn: conn} do - conn = get(conn, Routes.item_path(conn, :show, "invalid")) + conn = get(conn, Routes.api_item_path(conn, :show, "invalid")) assert conn.status == 400 end end describe "create" do test "a valid item", %{conn: conn} do - conn = post(conn, Routes.item_path(conn, :create, @create_attrs)) + conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs)) assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") @@ -43,7 +43,7 @@ defmodule AppWeb.API.ItemControllerTest do end test "an invalid item", %{conn: conn} do - conn = post(conn, Routes.item_path(conn, :create, @invalid_attrs)) + conn = post(conn, Routes.api_item_path(conn, :create, @invalid_attrs)) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 @@ -53,7 +53,7 @@ defmodule AppWeb.API.ItemControllerTest do describe "update" do test "item with valid attributes", %{conn: conn} do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) + conn = put(conn, Routes.api_item_path(conn, :update, item.id, @update_attrs)) assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) @@ -61,7 +61,7 @@ defmodule AppWeb.API.ItemControllerTest do test "item with invalid attributes", %{conn: conn} do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) + conn = put(conn, Routes.api_item_path(conn, :update, item.id, @invalid_attrs)) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 diff --git a/test/app_web/controllers/api/tag_controller_test.exs b/test/app_web/controllers/api/tag_controller_test.exs new file mode 100644 index 00000000..bb888179 --- /dev/null +++ b/test/app_web/controllers/api/tag_controller_test.exs @@ -0,0 +1,86 @@ +defmodule AppWeb.API.TagControllerTest do + use AppWeb.ConnCase + alias App.Tag + + @create_attrs %{person_id: 42, color: "#FFFFFF", text: "some text"} + @update_attrs %{person_id: 43, color: "#DDDDDD", text: "some updated text"} + @invalid_attrs %{person_id: nil, color: nil, text: nil} + @update_invalid_color %{color: "invalid"} + + describe "show" do + test "specific tag", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = get(conn, Routes.api_tag_path(conn, :show, tag.id)) + + assert conn.status == 200 + assert json_response(conn, 200)["id"] == tag.id + assert json_response(conn, 200)["text"] == tag.text + end + + test "not found tag", %{conn: conn} do + conn = get(conn, Routes.api_tag_path(conn, :show, -1)) + + assert conn.status == 404 + end + + test "invalid id (not being an integer)", %{conn: conn} do + conn = get(conn, Routes.api_tag_path(conn, :show, "invalid")) + assert conn.status == 400 + end + end + + describe "create" do + test "a valid tag", %{conn: conn} do + conn = post(conn, Routes.api_tag_path(conn, :create, @create_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") + + assert json_response(conn, 200)["color"] == + Map.get(@create_attrs, "color") + + assert json_response(conn, 200)["person_id"] == + Map.get(@create_attrs, "person_id") + end + + test "an invalid tag", %{conn: conn} do + conn = post(conn, Routes.api_tag_path(conn, :create, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + end + + describe "update" do + test "tag with valid attributes", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @update_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) + end + + test "tag with invalid attributes", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + + test "tag that doesn't exist", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = put(conn, Routes.api_tag_path(conn, :update, -1, @update_attrs)) + + assert conn.status == 404 + end + + test "a tag with invalid color", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @update_invalid_color)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["color"]) > 0 + end + end +end diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/app_web/controllers/api/timer_controller_test.exs index fcc53d56..4bf70b36 100644 --- a/test/app_web/controllers/api/timer_controller_test.exs +++ b/test/app_web/controllers/api/timer_controller_test.exs @@ -14,7 +14,7 @@ defmodule AppWeb.API.TimerControllerTest do # Create item and timer {item, timer} = item_and_timer_fixture() - conn = get(conn, Routes.timer_path(conn, :index, item.id)) + conn = get(conn, Routes.api_timer_path(conn, :index, item.id)) assert conn.status == 200 assert length(json_response(conn, 200)) == 1 @@ -26,7 +26,7 @@ defmodule AppWeb.API.TimerControllerTest do # Create item and timer {item, timer} = item_and_timer_fixture() - conn = get(conn, Routes.timer_path(conn, :show, item.id, timer.id)) + conn = get(conn, Routes.api_timer_path(conn, :show, item.id, timer.id)) assert conn.status == 200 assert json_response(conn, 200)["id"] == timer.id @@ -37,7 +37,7 @@ defmodule AppWeb.API.TimerControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) - conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) + conn = get(conn, Routes.api_timer_path(conn, :show, item.id, -1)) assert conn.status == 404 end @@ -47,7 +47,7 @@ defmodule AppWeb.API.TimerControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) - conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) + conn = get(conn, Routes.api_timer_path(conn, :show, item.id, "invalid")) assert conn.status == 400 end end @@ -60,7 +60,7 @@ defmodule AppWeb.API.TimerControllerTest do # Create timer conn = - post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) + post(conn, Routes.api_timer_path(conn, :create, item.id, @create_attrs)) assert conn.status == 200 @@ -74,7 +74,7 @@ defmodule AppWeb.API.TimerControllerTest do Item.create_item(@create_item_attrs) conn = - post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) + post(conn, Routes.api_timer_path(conn, :create, item.id, @invalid_attrs)) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 @@ -89,7 +89,7 @@ defmodule AppWeb.API.TimerControllerTest do conn = put( conn, - Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs) + Routes.api_timer_path(conn, :update, item.id, timer.id, @update_attrs) ) assert conn.status == 200 @@ -103,7 +103,7 @@ defmodule AppWeb.API.TimerControllerTest do conn = put( conn, - Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs) + Routes.api_timer_path(conn, :update, item.id, timer.id, @invalid_attrs) ) assert conn.status == 400 From dd07e6f97fd1cc638afc83f7252d5be3603d6e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 11:03:21 +0000 Subject: [PATCH 03/34] merge: Merging main into branch. --- .github/workflows/ci.yml | 4 +- BUILDIT.md | 18 ++- config/dev.exs | 2 +- .../api/item_controller.ex => api/item.ex} | 39 +++-- .../api/tag_controller.ex => api/tag.ex} | 2 +- lib/api/timer.ex | 152 ++++++++++++++++++ lib/app/item.ex | 17 ++ lib/app/timer.ex | 10 +- .../controllers/api/timer_controller.ex | 99 ------------ lib/app_web/live/app_live.ex | 12 +- lib/app_web/live/app_live.html.heex | 9 +- lib/app_web/router.ex | 10 +- lib/app_web/views/error_view.ex | 43 +++++ mix.exs | 4 +- mix.lock | 12 +- .../item_test.exs} | 13 +- .../tag_test.exs} | 2 +- .../timer_test.exs} | 58 ++++++- test/app/timer_test.exs | 4 +- test/app_web/live/app_live_test.exs | 9 ++ test/app_web/views/error_view_test.exs | 51 ++++++ 21 files changed, 417 insertions(+), 153 deletions(-) rename lib/{app_web/controllers/api/item_controller.ex => api/item.ex} (67%) rename lib/{app_web/controllers/api/tag_controller.ex => api/tag.ex} (98%) create mode 100644 lib/api/timer.ex delete mode 100644 lib/app_web/controllers/api/timer_controller.ex rename test/{app_web/controllers/api/item_controller_test.exs => api/item_test.exs} (91%) rename test/{app_web/controllers/api/tag_controller_test.exs => api/tag_test.exs} (98%) rename test/{app_web/controllers/api/timer_controller_test.exs => api/timer_test.exs} (70%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96a5a36d..33214be9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,8 @@ jobs: --health-retries 5 strategy: matrix: - otp: ['24.3.4'] - elixir: ['1.14.1'] + otp: ['25.1.2'] + elixir: ['1.14.2'] steps: - uses: actions/checkout@v2 - name: Set up Elixir diff --git a/BUILDIT.md b/BUILDIT.md index 5f31d873..4b1cc214 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -46,9 +46,10 @@ We suggest keeping two terminal tabs/windows running one for the server `mix phx.server` and the other for the tests. That way you can also see the UI as you progress. -We have created a *separate* -document detailing the implementation of the API. -You can find it in [`api.md`](./api.md). +We created a *separate* +document detailing the implementation of the `API`. +Please see: +[`API.md`](./API.md). With that in place, let's get building! @@ -105,7 +106,7 @@ With that in place, let's get building! - [13.1 Setting up](#131-setting-up) - [13.2 Changing database transactions on `item` insert and update](#132-changing-database-transactions-on-item-insert-and-update) - [13.3 Fixing tests](#133-fixing-tests) - - [13.4 Checking the changes using `DBEaver`](#134-checking-the-changes-using-DBEaver) + - [13.4 Checking the changes using `DBEaver`](#134-checking-the-changes-using-dbeaver) - [14. Adding a dashboard to track metrics](#14-adding-a-dashboard-to-track-metrics) - [14.1 Adding new `LiveView` page in `/stats`](#141-adding-new-liveview-page-in-stats) - [14.2 Fetching counter of timers and items for each person](#142-fetching-counter-of-timers-and-items-for-each-person) @@ -2937,8 +2938,13 @@ in the same file. } case Timer.update_timer_inside_changeset_list( timer, index, timer_changeset_list) do - {:ok, _list} -> {:noreply, assign(socket, editing: nil, editing_timers: [])} - {:error, updated_errored_list} -> {:noreply, assign(socket, editing_timers: updated_errored_list)} + {:ok, _list} -> + # Updates item list and broadcast to other clients + AppWeb.Endpoint.broadcast(@topic, "update", :update) + {:noreply, assign(socket, editing: nil, editing_timers: [])} + + {:error, updated_errored_list} -> + {:noreply, assign(socket, editing_timers: updated_errored_list)} end end ``` diff --git a/config/dev.exs b/config/dev.exs index 480c52f9..6f716d6d 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -22,7 +22,7 @@ config :app, AppWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4000], check_origin: false, code_reloader: true, - debug_errors: true, + debug_errors: false, secret_key_base: "IyvSJdeCw6Z8RkFvfK3hsoU6rSRo6B2/5ltW0EGBjuIdQEDy/bYcYzajk32Kbems", watchers: [ diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/api/item.ex similarity index 67% rename from lib/app_web/controllers/api/item_controller.ex rename to lib/api/item.ex index c271d1d3..fac4cbde 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/api/item.ex @@ -1,4 +1,4 @@ -defmodule AppWeb.API.ItemController do +defmodule API.Item do use AppWeb, :controller alias App.Item import Ecto.Changeset @@ -24,7 +24,7 @@ defmodule AppWeb.API.ItemController do :error -> errors = %{ code: 400, - message: "The \'id\' is not an integer." + message: "Item \'id\' should be an integer." } json(conn |> put_status(400), errors) @@ -61,21 +61,32 @@ defmodule AppWeb.API.ItemController do id = Map.get(params, "id") new_text = Map.get(params, "text") - item = Item.get_item!(id) + # Get item with the ID + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } - case Item.update_item(item, %{text: new_text}) do - # Successfully updates item - {:ok, %{model: item, version: _version}} -> - json(conn, item) + json(conn |> put_status(404), errors) - # Error creating item - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) + # If item is found, try to update it + item -> + case Item.update_item(item, %{text: new_text}) do + # Successfully updates item + {:ok, %{model: item, version: _version}} -> + json(conn, item) - json( - conn |> put_status(400), - errors - ) + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end end end diff --git a/lib/app_web/controllers/api/tag_controller.ex b/lib/api/tag.ex similarity index 98% rename from lib/app_web/controllers/api/tag_controller.ex rename to lib/api/tag.ex index 242d2d26..32dfefc2 100644 --- a/lib/app_web/controllers/api/tag_controller.ex +++ b/lib/api/tag.ex @@ -1,4 +1,4 @@ -defmodule AppWeb.API.TagController do +defmodule API.Tag do use AppWeb, :controller alias App.Tag import Ecto.Changeset diff --git a/lib/api/timer.ex b/lib/api/timer.ex new file mode 100644 index 00000000..3f995c4b --- /dev/null +++ b/lib/api/timer.ex @@ -0,0 +1,152 @@ +defmodule API.Timer do + use AppWeb, :controller + alias App.Timer + import Ecto.Changeset + + def index(conn, params) do + item_id = Map.get(params, "item_id") + + timers = Timer.list_timers(item_id) + json(conn, timers) + end + + def stop(conn, params) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + id = Map.get(params, "id") + + # Attributes to update timer + attrs_to_update = %{ + stop: now + } + + # Fetching associated timer + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + # If timer is found, try to update it + timer -> + # If the timer has already stopped, throw error + if not is_nil(timer.stop) do + errors = %{ + code: 403, + message: "Timer with the given \'id\' has already stopped." + } + + json(conn |> put_status(403), errors) + + # If timer is ongoing, try to update + else + case Timer.update_timer(timer, attrs_to_update) do + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) + end + end + end + end + + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + timer -> + json(conn, timer) + end + + # ID is not an integer + :error -> + errors = %{ + code: 400, + message: "Timer \'id\' should be an integer." + } + + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + + # Attributes to create timer + attrs = %{ + item_id: Map.get(params, "item_id"), + start: Map.get(params, "start", now), + stop: Map.get(params, "stop") + } + + case Timer.start(attrs) do + # Successfully creates timer + {:ok, timer} -> + id_timer = Map.take(timer, [:id]) + json(conn, id_timer) + + # Error creating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + + # Attributes to update timer + attrs_to_update = %{ + start: Map.get(params, "start"), + stop: Map.get(params, "stop") + } + + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + # If timer is found, try to update it + timer -> + case Timer.update_timer(timer, attrs_to_update) do + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) + + # Error updating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json(conn |> put_status(400), errors) + end + end + end + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request" + } + + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) + Map.put(errors, :errors, changeset_errors) + end +end diff --git a/lib/app/item.ex b/lib/app/item.ex index 002fcaea..3b644746 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -31,6 +31,12 @@ defmodule App.Item do |> put_assoc(:tags, attrs.tags) end + def draft_changeset(item, attrs) do + item + |> cast(attrs, [:person_id, :status, :text]) + |> validate_required([:person_id]) + end + @doc """ Creates an `item`. @@ -86,6 +92,11 @@ defmodule App.Item do |> Repo.preload(tags: from(t in Tag, order_by: t.text)) end + def get_draft_item(person_id) do + Repo.get_by(Item, status: 7, person_id: person_id) || + Repo.insert!(%Item{person_id: person_id, status: 7}) + end + @doc """ `get_item/1` gets a single Item. @@ -146,6 +157,12 @@ defmodule App.Item do |> PaperTrail.update(originator: %{id: Map.get(attrs, :person_id, 0)}) end + def update_draft(%Item{} = item, attrs) do + item + |> Item.draft_changeset(attrs) + |> Repo.update() + end + @doc """ Update an item and its associated tags """ diff --git a/lib/app/timer.ex b/lib/app/timer.ex index dfb0965e..87a16a4e 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -130,8 +130,8 @@ defmodule App.Timer do {:ok, %Timer{id: 1, start: ~N[2022-07-11 05:15:31], stop: ~N[2022-07-11 05:15:37}} """ - def update_timer(attrs \\ %{}) do - get_timer!(attrs.id) + def update_timer(%Timer{} = timer, attrs \\ %{}) do + timer |> changeset(attrs) |> validate_start_before_stop() |> Repo.update() @@ -177,7 +177,8 @@ defmodule App.Timer do case NaiveDateTime.compare(start, max_end) do :gt -> - update_timer(%{id: timer_id, start: start, stop: nil}) + timer = get_timer(timer_id) + update_timer(timer, %{start: start, stop: nil}) {:ok, []} _ -> @@ -276,7 +277,8 @@ defmodule App.Timer do end end - update_timer(%{id: timer_id, start: start, stop: stop}) + timer = get_timer(timer_id) + update_timer(timer, %{start: start, stop: stop}) {:ok, []} :eq -> diff --git a/lib/app_web/controllers/api/timer_controller.ex b/lib/app_web/controllers/api/timer_controller.ex deleted file mode 100644 index c7c2163f..00000000 --- a/lib/app_web/controllers/api/timer_controller.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule AppWeb.API.TimerController do - use AppWeb, :controller - alias App.Timer - import Ecto.Changeset - - def index(conn, params) do - item_id = Map.get(params, "item_id") - - timers = Timer.list_timers(item_id) - json(conn, timers) - end - - def show(conn, %{"id" => id} = _params) do - case Integer.parse(id) do - # ID is an integer - {id, _float} -> - case Timer.get_timer(id) do - nil -> - errors = %{ - code: 404, - message: "No timer found with the given \'id\'." - } - - json(conn |> put_status(404), errors) - - timer -> - json(conn, timer) - end - - # ID is not an integer - :error -> - errors = %{ - code: 400, - message: "The \'id\' is not an integer." - } - - json(conn |> put_status(400), errors) - end - end - - def create(conn, params) do - # Attributes to create timer - attrs = %{ - item_id: Map.get(params, "item_id"), - start: Map.get(params, "start"), - stop: Map.get(params, "stop") - } - - case Timer.start(attrs) do - # Successfully creates item - {:ok, timer} -> - id_timer = Map.take(timer, [:id]) - json(conn, id_timer) - - # Error creating item - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) - - json( - conn |> put_status(400), - errors - ) - end - end - - def update(conn, params) do - # Attributes to update timer - attrs_to_update = %{ - id: Map.get(params, "id"), - start: Map.get(params, "start"), - stop: Map.get(params, "stop") - } - - case Timer.update_timer(attrs_to_update) do - # Successfully updates timer - {:ok, timer} -> - json(conn, timer) - - # Error creating timer - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) - - json( - conn |> put_status(400), - errors - ) - end - end - - defp make_changeset_errors_readable(changeset) do - errors = %{ - code: 400, - message: "Malformed request" - } - - changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) - Map.put(errors, :errors, changeset_errors) - end -end diff --git a/lib/app_web/live/app_live.ex b/lib/app_web/live/app_live.ex index e16a50a9..e44e6550 100644 --- a/lib/app_web/live/app_live.ex +++ b/lib/app_web/live/app_live.ex @@ -22,6 +22,7 @@ defmodule AppWeb.AppLive do items = Item.items_with_timers(person_id) tags = Tag.list_person_tags(person_id) selected_tags = [] + draft_item = Item.get_draft_item(person_id) {:ok, assign(socket, @@ -32,12 +33,16 @@ defmodule AppWeb.AppLive do filter_tag: nil, tags: tags, selected_tags: selected_tags, - text_value: "" + text_value: draft_item.text || "" )} end @impl true def handle_event("validate", %{"text" => text}, socket) do + person_id = get_person_id(socket.assigns) + draft = Item.get_draft_item(person_id) + Item.update_draft(draft, %{text: text}) + # only save draft if person id != 0 (ie not guest) {:noreply, assign(socket, text_value: text)} end @@ -52,6 +57,9 @@ defmodule AppWeb.AppLive do tags: socket.assigns.selected_tags }) + draft = Item.get_draft_item(person_id) + Item.update_draft(draft, %{text: ""}) + AppWeb.Endpoint.broadcast(@topic, "update", :create) AppWeb.Endpoint.broadcast( @@ -203,6 +211,8 @@ defmodule AppWeb.AppLive do timer_changeset_list ) do {:ok, _list} -> + # Updates item list and broadcast to other users + AppWeb.Endpoint.broadcast(@topic, "update", :update) {:noreply, assign(socket, editing: nil, editing_timers: [])} {:error, updated_errored_list} -> diff --git a/lib/app_web/live/app_live.html.heex b/lib/app_web/live/app_live.html.heex index 952aad21..212f1dd1 100644 --- a/lib/app_web/live/app_live.html.heex +++ b/lib/app_web/live/app_live.html.heex @@ -15,16 +15,21 @@ focus:border-none focus:outline-none my-2" name="text" + id="textval" phx-change="validate" + phx-debounce="2000" placeholder="What needs to be done?!!!" - autofocus="true" required="required" x-data="{resize() { $el.style.height = '100px'; $el.style.height = $el.scrollHeight + 'px'; } }" - x-init="resize" + x-on:focus="" + x-init="$el.selectionStart= $el.value.length; + $el.selectionEnd= $el.value.length; + $el.focus(); + resize" x-on:input="resize" ><%= @text_value %> diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index 0dc50373..05a33463 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -34,14 +34,16 @@ defmodule AppWeb.Router do resources "/tags", TagController, except: [:show] end - scope "/api", AppWeb, as: :api do + scope "/api", API, as: :api do pipe_through [:api, :authOptional] - resources "/items", API.ItemController, only: [:create, :update, :show] + resources "/items", Item, only: [:create, :update, :show] - resources "/items/:item_id/timers", API.TimerController, + resources "/items/:item_id/timers", Timer, only: [:create, :update, :show, :index] - resources "/tags", API.TagController, only: [:create, :update, :show] + put "/timers/:id", Timer, :stop + + resources "/tags", Tag, only: [:create, :update, :show] end end diff --git a/lib/app_web/views/error_view.ex b/lib/app_web/views/error_view.ex index a6651a5c..fcd1b92a 100644 --- a/lib/app_web/views/error_view.ex +++ b/lib/app_web/views/error_view.ex @@ -10,6 +10,49 @@ defmodule AppWeb.ErrorView do # By default, Phoenix returns the status message from # the template name. For example, "404.html" becomes # "Not Found". + def template_not_found(template, %{:conn => conn}) do + acceptHeader = + Enum.at(Plug.Conn.get_req_header(conn, "content-type"), 0, "") + + isJson = + String.contains?(acceptHeader, "application/json") or + String.match?(template, ~r/.*\.json/) + + if isJson do + # If `Content-Type` is `json` but the `Accept` header is not passed, Phoenix considers this as an `.html` request. + # We want to return a JSON, hence why we check if Phoenix considers this an `.html` request. + # + # If so, we return a JSON with appropriate headers. + # We try to return a meaningful error if it exists (:reason). It it doesn't, we return the status message from template + case String.match?(template, ~r/.*\.json/) do + true -> + %{ + error: + Map.get( + conn.assigns.reason, + :message, + Phoenix.Controller.status_message_from_template(template) + ) + } + + false -> + Phoenix.Controller.json( + conn, + %{ + error: + Map.get( + conn.assigns.reason, + :message, + Phoenix.Controller.status_message_from_template(template) + ) + } + ) + end + else + Phoenix.Controller.status_message_from_template(template) + end + end + def template_not_found(template, _assigns) do Phoenix.Controller.status_message_from_template(template) end diff --git a/mix.exs b/mix.exs index 389cf8cd..1fe3e6ae 100644 --- a/mix.exs +++ b/mix.exs @@ -57,7 +57,7 @@ defmodule App.MixProject do {:plug_cowboy, "~> 2.5"}, # Database changes tracking - {:paper_trail, "~> 0.14.3"}, + {:paper_trail, "~> 1.0.0"}, # Time string parsing: github.com/bitwalker/timex {:timex, "~> 3.7"}, @@ -72,7 +72,7 @@ defmodule App.MixProject do {:fields, "~> 2.10.3"}, # Useful functions: github.com/dwyl/useful - {:useful, "~> 1.0.8", override: true}, + {:useful, "~> 1.10.0", override: true}, # See: github.com/dwyl/useful/issues/17 {:atomic_map, "~> 0.9.3"}, diff --git a/mix.lock b/mix.lock index 1c2b42fc..c9f9ab2e 100644 --- a/mix.lock +++ b/mix.lock @@ -16,7 +16,7 @@ "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, "ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"}, - "ecto_sql": {:hex, :ecto_sql, "3.9.1", "9bd5894eecc53d5b39d0c95180d4466aff00e10679e13a5cfa725f6f85c03c22", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fd470a4fff2e829bbf9dcceb7f3f9f6d1e49b4241e802f614de6b8b67c51118"}, + "ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"}, "elixir_make": {:hex, :elixir_make, "0.7.0", "03e6a43ac701a2afee73bb5dd030b4dcddcb403bf81abb4753c1da64521cd05d", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "b1ccb45cc1c5df16349473b52adbd718f362585a5481d761b8a9fdb45f25b303"}, "envar": {:hex, :envar, "1.1.0", "105bcac5a03800a1eb21e2c7e229edc687359b0cc184150ec1380db5928c115c", [:mix], [], "hexpm", "97028ab4a040a5c19e613fdf46a41cf51c6e848d99077e525b338e21d2993320"}, "esbuild": {:hex, :esbuild, "0.5.0", "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5"}, @@ -43,14 +43,14 @@ "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mochiweb": {:hex, :mochiweb, "2.22.0", "f104d6747c01a330c38613561977e565b788b9170055c5241ac9dd6e4617cba5", [:rebar3], [], "hexpm", "cbbd1fd315d283c576d1c8a13e0738f6dafb63dc840611249608697502a07655"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, - "paper_trail": {:hex, :paper_trail, "0.14.5", "af0f8b2d38929cd3fe9001c4b5607c7ebf01d9ef1db168db1202b38a6db015c3", [:mix], [{:ecto, ">= 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, ">= 3.9.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "9a3efa7bbf0fd619b5eb8d766a009c1e475c848e9e40f2e6a2dbe7e4579235d2"}, + "paper_trail": {:hex, :paper_trail, "1.0.0", "afe08da1b6c07bdd300bfc087a23e22c8706f8649182d3bd549e32427fea7850", [:mix], [{:ecto, ">= 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, ">= 3.9.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "bbfe0c85f898a478efb508ce340a90acdac6c3b6715470dbc60f99d4ab23af56"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "petal_components": {:hex, :petal_components, "0.19.8", "8dc399e76c7b8a28c1f2c81b982a1f92264b77c1438bf875f33d2aef59497d84", [:mix], [{:heroicons, "~> 0.5.0", [hex: :heroicons, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "5c547e68b575f1d4523e5163a3150b817dc78bf55dc627f102360bbe193a283d"}, + "petal_components": {:hex, :petal_components, "0.19.10", "a8d9aba43885d99f2f0770c0932aeae75e18f41741d7a480c6bbbbb645e48bd9", [:mix], [{:heroicons, "~> 0.5.0", [hex: :heroicons, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "023d80fe16ce032f2323d86b1b63aa3c1190d17366c83541ab4273b44b14bdca"}, "phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.6", "460c36977643d76fc8e0b6b3c4bba703c0ef21abc74233cf7dc15d1c1696832f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce2768fb44c3c370df13fc4f0dc70623b662a93a201d8d7d87c4ba6542bc6b73"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.11", "c50eac83dae6b5488859180422dfb27b2c609de87f4aa5b9c926ecd0501cd44f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76c99a0ffb47cd95bf06a917e74f282a603f3e77b00375f3c2dd95110971b102"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, "phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"}, "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, @@ -63,11 +63,11 @@ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "statuses": {:hex, :statuses, "1.1.1", "bb52e1d7d3d16624c592a8a7712ddd03ea19f11cf8df54d8604c20d220b358e9", [:mix], [], "hexpm", "9dbc3839547401153dce11b8ab1bc406339e18d4c67eb0449c38749e26ba2e27"}, "tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"}, - "telemetry": {:hex, :telemetry, "1.2.0", "a8ce551485a9a3dac8d523542de130eafd12e40bbf76cf0ecd2528f24e812a44", [:rebar3], [], "hexpm", "1427e73667b9a2002cf1f26694c422d5c905df889023903c4518921d53e3e883"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, "timex": {:hex, :timex, "3.7.9", "790cdfc4acfce434e442f98c02ea6d84d0239073bfd668968f82ac63e9a6788d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "64691582e5bb87130f721fc709acfb70f24405833998fabf35be968984860ce1"}, "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, - "useful": {:hex, :useful, "1.0.8", "795a5bf94567e4a1b374621577acabc80ea80634b634095237f98e40e64e9d24", [:mix], [], "hexpm", "947ae0ba2b3c06bcfd8994e95e29f4cc13287aab81b481ae6abb9077fc9c1ad5"}, + "useful": {:hex, :useful, "1.10.0", "a9377b3d8765d28dbb99332f37ab73f5a85706bef918072383957c9294d7bfbc", [:mix], [], "hexpm", "39c5535370943604ee185f46a5d2d9e6956568e48e0120fcda988653b5acbfc3"}, } diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/api/item_test.exs similarity index 91% rename from test/app_web/controllers/api/item_controller_test.exs rename to test/api/item_test.exs index d4e7a68b..400c56f4 100644 --- a/test/app_web/controllers/api/item_controller_test.exs +++ b/test/api/item_test.exs @@ -1,4 +1,4 @@ -defmodule AppWeb.API.ItemControllerTest do +defmodule API.ItemTest do use AppWeb.ConnCase alias App.Item @@ -11,7 +11,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = get(conn, Routes.api_item_path(conn, :show, item.id)) - assert conn.status == 200 assert json_response(conn, 200)["id"] == item.id assert json_response(conn, 200)["text"] == item.text end @@ -32,7 +31,6 @@ defmodule AppWeb.API.ItemControllerTest do test "a valid item", %{conn: conn} do conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs)) - assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") assert json_response(conn, 200)["status"] == @@ -45,7 +43,6 @@ defmodule AppWeb.API.ItemControllerTest do test "an invalid item", %{conn: conn} do conn = post(conn, Routes.api_item_path(conn, :create, @invalid_attrs)) - assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end end @@ -55,7 +52,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.api_item_path(conn, :update, item.id, @update_attrs)) - assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) end @@ -63,8 +59,13 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.api_item_path(conn, :update, item.id, @invalid_attrs)) - assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end + + test "item that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.api_item_path(conn, :update, -1, @invalid_attrs)) + + assert conn.status == 404 + end end end diff --git a/test/app_web/controllers/api/tag_controller_test.exs b/test/api/tag_test.exs similarity index 98% rename from test/app_web/controllers/api/tag_controller_test.exs rename to test/api/tag_test.exs index bb888179..763d54ca 100644 --- a/test/app_web/controllers/api/tag_controller_test.exs +++ b/test/api/tag_test.exs @@ -1,4 +1,4 @@ -defmodule AppWeb.API.TagControllerTest do +defmodule API.TagTest do use AppWeb.ConnCase alias App.Tag diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/api/timer_test.exs similarity index 70% rename from test/app_web/controllers/api/timer_controller_test.exs rename to test/api/timer_test.exs index 4bf70b36..a028e82c 100644 --- a/test/app_web/controllers/api/timer_controller_test.exs +++ b/test/api/timer_test.exs @@ -1,4 +1,4 @@ -defmodule AppWeb.API.TimerControllerTest do +defmodule API.TimerTest do use AppWeb.ConnCase alias App.Timer alias App.Item @@ -12,7 +12,7 @@ defmodule AppWeb.API.TimerControllerTest do describe "index" do test "timers", %{conn: conn} do # Create item and timer - {item, timer} = item_and_timer_fixture() + {item, _timer} = item_and_timer_fixture() conn = get(conn, Routes.api_timer_path(conn, :index, item.id)) @@ -79,6 +79,54 @@ defmodule AppWeb.API.TimerControllerTest do assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 end + + test "a timer with empty body", %{conn: conn} do + # Create item + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) + + conn = post(conn, Routes.api_timer_path(conn, :create, item.id, %{})) + + assert conn.status == 200 + end + end + + describe "stop" do + test "timer without any attributes", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = + put( + conn, + Routes.api_timer_path(conn, :stop, timer.id, %{}) + ) + + assert conn.status == 200 + end + + test "timer that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.api_timer_path(conn, :stop, -1, %{})) + + assert conn.status == 404 + end + + test "timer that has already stopped", %{conn: conn} do + # Create item and timer + {_item, timer} = item_and_timer_fixture() + + # Stop timer + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + {:ok, timer} = Timer.update_timer(timer, %{stop: now}) + + conn = + put( + conn, + Routes.api_timer_path(conn, :stop, timer.id, %{}) + ) + + assert conn.status == 403 + end end describe "update" do @@ -109,6 +157,12 @@ defmodule AppWeb.API.TimerControllerTest do assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 end + + test "timer that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.api_timer_path(conn, :update, -1, -1, @invalid_attrs)) + + assert conn.status == 404 + end end defp item_and_timer_fixture() do diff --git a/test/app/timer_test.exs b/test/app/timer_test.exs index f6baaad3..b842bdf0 100644 --- a/test/app/timer_test.exs +++ b/test/app/timer_test.exs @@ -89,7 +89,7 @@ defmodule App.TimerTest do Timer.stop_timer_for_item_id(item.id) # Update timer to specific datetimes - Timer.update_timer(%{id: timer.id, start: start, stop: stop}) + Timer.update_timer(timer, %{start: start, stop: stop}) updated_timer = Timer.get_timer!(timer.id) @@ -116,7 +116,7 @@ defmodule App.TimerTest do # Update timer with stop earlier than start {:error, changeset} = - Timer.update_timer(%{id: timer.id, start: start, stop: stop}) + Timer.update_timer(timer, %{start: start, stop: stop}) assert length(changeset.errors) > 0 end diff --git a/test/app_web/live/app_live_test.exs b/test/app_web/live/app_live_test.exs index c561f674..4a4856e1 100644 --- a/test/app_web/live/app_live_test.exs +++ b/test/app_web/live/app_live_test.exs @@ -501,6 +501,15 @@ defmodule AppWeb.AppLiveTest do }) =~ "This timer interval overlaps with other timers." end + test "timer_text(start, stop) UNDER 1000s" do + timer = %{ + start: ~N[2022-07-17 09:01:42.000000], + stop: ~N[2022-07-17 09:02:24.000000] + } + + assert AppWeb.AppLive.timer_text(timer) == "00:00:42" + end + test "timer_text(start, stop)" do timer = %{ start: ~N[2022-07-17 09:01:42.000000], diff --git a/test/app_web/views/error_view_test.exs b/test/app_web/views/error_view_test.exs index 6313302a..bd1b4e05 100644 --- a/test/app_web/views/error_view_test.exs +++ b/test/app_web/views/error_view_test.exs @@ -1,4 +1,5 @@ defmodule AppWeb.ErrorViewTest do + alias Phoenix.ConnTest use AppWeb.ConnCase, async: true # Bring render/3 and render_to_string/3 for testing custom views @@ -12,4 +13,54 @@ defmodule AppWeb.ErrorViewTest do assert render_to_string(AppWeb.ErrorView, "500.html", []) == "Internal Server Error" end + + test "testing error view with `Accept` header with `application/json` and passing a `.json` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("accept", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + assert Jason.decode!(render_to_string(AppWeb.ErrorView, "404.json", conn)) == + %{"error" => "Route not found."} + end + + test "testing error view with `Content-type` header with `application/json` and passing a `.json` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("content-type", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + assert Jason.decode!(render_to_string(AppWeb.ErrorView, "404.json", conn)) == + %{"error" => "Route not found."} + end + + test "testing error view with `Content-type` header with `application/json` and passing a `.html` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("content-type", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + resp_body = Map.get(render(AppWeb.ErrorView, "404.html", conn), :resp_body) + + assert Jason.decode!(resp_body) == %{"error" => "Route not found."} + end + + test "testing error view and passing a `.html` template" do + conn = build_conn() + conn = %{conn: conn} + + assert render_to_string(AppWeb.ErrorView, "404.html", conn) == "Not Found" + end end From ed665c5b9ad56eba39ae7e8f57b3118d315e4917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 11:19:58 +0000 Subject: [PATCH 04/34] merge: Merging main into branch. --- .github/workflows/ci.yml | 64 +- .gitignore | 1 + .prettierrc | 4 + API.md | 1601 +++++++++++++++++++++++++++ api.md | 1000 +++++++++++++++-- config/test.exs | 2 +- lib/api/MVP.json | 368 ++++++ lib/api/api_test_mock_data.sql | 10 + lib/api/envs.json | 35 + lib/api/localhost.json | 9 + lib/app/timer.ex | 4 +- lib/app_web/live/app_live.html.heex | 2 +- test/api/timer_test.exs | 1 + test/app_web/live/app_live_test.exs | 13 +- 14 files changed, 3017 insertions(+), 97 deletions(-) create mode 100644 .prettierrc create mode 100644 API.md create mode 100644 lib/api/MVP.json create mode 100644 lib/api/api_test_mock_data.sql create mode 100644 lib/api/envs.json create mode 100644 lib/api/localhost.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33214be9..833233b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,8 @@ on: branches: [ main ] jobs: + + # Build and testing build: name: Build and test runs-on: ubuntu-latest @@ -54,12 +56,70 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 + # API Definition testing + # https://docs.hoppscotch.io/cli + api_definition: + name: API Definition Tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:12 + ports: ['5432:5432'] + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + matrix: + otp: ['25.1.2'] + elixir: ['1.14.2'] + steps: + - uses: actions/checkout@v2 + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - name: Restore deps and _build cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} + - name: Install dependencies + run: mix deps.get + + - name: Install Hoppscotch CLI + run: npm i -g @hoppscotch/cli + + - name: Run mix ecto.create + run: mix ecto.create + + - name: Run ecto.migrate + run: mix ecto.migrate + + - name: Bootstrap Postgres DB with data + run: psql -h localhost -p 5432 -d app_dev -U postgres -f ./lib/api/api_test_mock_data.sql + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + PGPASSWORD: postgres + + - name: Running server and Hoppscotch Tests + run: mix phx.server & sleep 5 && hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json + # Continuous Deployment to Fly.io # https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ deploy: name: Deploy app runs-on: ubuntu-latest - needs: build + needs: [build, api_definition] # https://stackoverflow.com/questions/58139406/only-run-job-on-specific-branch-with-github-actions if: github.ref == 'refs/heads/main' env: @@ -68,4 +128,4 @@ jobs: - uses: actions/checkout@v2 - uses: superfly/flyctl-actions@1.1 with: - args: "deploy" + args: "deploy" \ No newline at end of file diff --git a/.gitignore b/.gitignore index be491ac3..487397d0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ npm-debug.log .vscode/ .env +hoppscotch/node_modules diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..222861c3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/API.md b/API.md new file mode 100644 index 00000000..8a7557e1 --- /dev/null +++ b/API.md @@ -0,0 +1,1601 @@ +
+ +# `API` Integration + +
+ +This guide demonstrates +how to *extend* our MVP `Phoenix` App +so it also acts as an **`API`** +returning `JSON` data. + +`people` want to securely query +and update their data. +We want to ensure all actions +that are performed in the Web UI +can also be done through our `REST API` +*and* `WebSocket API` +(for all real-time updates). + + +
+ +- [`API` Integration](#api-integration) +- [1. Add `/api` scope and pipeline in `router.ex`](#1-add-api-scope-and-pipeline-in-routerex) +- [2. `API.Item` and `API.Timer`](#2-apiitem-and-apitimer) + - [2.1 Adding tests](#21-adding-tests) + - [2.2 Implementing the controllers](#22-implementing-the-controllers) +- [3. `JSON` serializing](#3-json-serializing) +- [4. Listing `timers` and `items` and validating updates](#4-listing-timers-and-items-and-validating-updates) +- [5. Error handling in `ErrorView`](#5-error-handling-in-errorview) +- [5.1 Fixing tests](#51-fixing-tests) +- [6. Basic `API` Testing Using `cUrl`](#6-basic-api-testing-using-curl) + - [6.1 _Create_ an `item` via `API` Request](#61-create-an-item-via-api-request) + - [6.2 _Read_ the `item` via `API`](#62-read-the-item-via-api) + - [6.3 Create a `Timer` for your `item`](#63-create-a-timer-for-your-item) + - [6.4 _Stop_ the `Timer`](#64-stop-the-timer) + - [6.5 Updating a `Timer`](#65-updating-a-timer) +- [7. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#7-advancedautomated-api-testing-using-hoppscotch) + - [7.0 `Hoppscotch` Setup](#70-hoppscotch-setup) + - [7.1 Using `Hoppscotch`](#71-using-hoppscotch) + - [7.2 Integration with `Github Actions` with `Hoppscotch CLI`](#72-integration-with-github-actions-with-hoppscotch-cli) +- [7.2.1 Changing the workflow `.yml` file](#721-changing-the-workflow-yml-file) +- [Done! ✅](#done-) + + +
+ + +# 1. Add `/api` scope and pipeline in `router.ex` + +We want all `API` requests +to be made under the `/api` namespace. +This is easier for us to manage changes to `API` +that don't create unnecessary complexity in the `LiveView` code. + +Let's start by opening `lib/router.ex` +and create a new `:api` pipeline +to be used under `scope "/api"`: + +```elixir + + pipeline :api do + plug :accepts, ["json"] + plug :fetch_session + end + + pipeline :authOptional do + plug(AuthPlugOptional) + end + + scope "/api", AppWeb do + pipe_through [:api, :authOptional] + + resources "/items", API.ItemController, only: [:create, :update, :show] + resources "/items/:item_id/timers", API.TimerController, only: [:create, :update, :show, :index] + + put "/timers/:id", Timer, :stop + end +``` + +We are creating an `:api` pipeline +that will only accept and return `json` objects. +`:fetch_session` is added as a plug +because `:authOptional` requires us to do so. + +Every request that is routed to `/api` +will be piped through both the `:api` and `:authOptional` pipelines. + +You might have noticed two new controllers: +`API.ItemController` and `API.TimerController`. +We are going to need to create these to handle our requests! + +# 2. `API.Item` and `API.Timer` + +Before creating our controller, let's define our requirements. We want the API to: + +- read contents of an `item`/`timer` +- list `timers` of an `item` +- create an `item` and return only the created `id`. +- edit an `item` +- stop a `timer` +- update a `timer` +- create a `timer` + +We want each endpoint to respond appropriately if any data is invalid, +the response body and status should inform the `person` what went wrong. +We can leverage changesets to validate the `item` and `timer` +and check if it's correctly formatted. + +## 2.1 Adding tests + +Let's approach this +with a [`TDD mindset`](https://github.com/dwyl/learn-tdd) +and create our tests first! + +Create two new files: +- `test/api/item_test.exs` +- `test/api/timer_test.exs` + +Before implementing, +we recommend giving a look at +[`learn-api-design`](https://github.com/dwyl/learn-api-design), +we are going to be using some best practices described there! + +We want the `API` requests +to be handled gracefully +when an error occurs. +The `person` using the `API` +[**should be shown _meaningful_ errors**](https://github.com/dwyl/learn-api-design/blob/main/README.md#show-meaningful-errors). +Therefore, we need to test how our API behaves +when invalid attributes are requested +and/or an error occurs **and where**. + +Open `test/api/item_test.exs` +and add the following code: + +```elixir +defmodule API.ItemTest do +use AppWeb.ConnCase + alias App.Item + + @create_attrs %{person_id: 42, status: 0, text: "some text"} + @update_attrs %{person_id: 43, status: 0, text: "some updated text"} + @invalid_attrs %{person_id: nil, status: nil, text: nil} + + describe "show" do + test "specific item", %{conn: conn} do + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) + conn = get(conn, Routes.item_path(conn, :show, item.id)) + + assert json_response(conn, 200)["id"] == item.id + assert json_response(conn, 200)["text"] == item.text + end + + test "not found item", %{conn: conn} do + conn = get(conn, Routes.item_path(conn, :show, -1)) + + assert conn.status == 404 + end + + test "invalid id (not being an integer)", %{conn: conn} do + conn = get(conn, Routes.item_path(conn, :show, "invalid")) + assert conn.status == 400 + end + end + + describe "create" do + test "a valid item", %{conn: conn} do + conn = post(conn, Routes.item_path(conn, :create, @create_attrs)) + + assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") + + assert json_response(conn, 200)["status"] == + Map.get(@create_attrs, "status") + + assert json_response(conn, 200)["person_id"] == + Map.get(@create_attrs, "person_id") + end + + test "an invalid item", %{conn: conn} do + conn = post(conn, Routes.item_path(conn, :create, @invalid_attrs)) + + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + end + + describe "update" do + test "item with valid attributes", %{conn: conn} do + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) + conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) + + assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) + end + + test "item with invalid attributes", %{conn: conn} do + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) + conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) + + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + + test "item that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.item_path(conn, :update, -1, @invalid_attrs)) + + assert conn.status == 404 + end + end +end +``` + +In `/item`, +a `person` will be able to +**create**, **update** or **query a single item**. +In each test we are testing +successful scenarios (the [Happy Path](https://en.wikipedia.org/wiki/Happy_path)), +alongside situations where the person +requests non-existent items +or tries to create new ones with invalid attributes. + +Next, in the `test/api/timer_test.exs` file: + +```elixir +defmodule API.TimerTest do + use AppWeb.ConnCase + alias App.Timer + alias App.Item + + @create_item_attrs %{person_id: 42, status: 0, text: "some text"} + + @create_attrs %{item_id: 42, start: "2022-10-27T00:00:00"} + @update_attrs %{item_id: 43, start: "2022-10-28T00:00:00"} + @invalid_attrs %{item_id: nil, start: nil} + + describe "index" do + test "timers", %{conn: conn} do + # Create item and timer + {item, _timer} = item_and_timer_fixture() + + conn = get(conn, Routes.timer_path(conn, :index, item.id)) + + assert conn.status == 200 + assert length(json_response(conn, 200)) == 1 + end + end + + describe "show" do + test "specific timer", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = get(conn, Routes.timer_path(conn, :show, item.id, timer.id)) + + assert conn.status == 200 + assert json_response(conn, 200)["id"] == timer.id + end + + test "not found timer", %{conn: conn} do + # Create item + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) + + conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) + + assert conn.status == 404 + end + + test "invalid id (not being an integer)", %{conn: conn} do + # Create item + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) + + conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) + assert conn.status == 400 + end + end + + describe "create" do + test "a valid timer", %{conn: conn} do + # Create item + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) + + # Create timer + conn = + post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) + + assert conn.status == 200 + + assert json_response(conn, 200)["start"] == + Map.get(@create_attrs, "start") + end + + test "an invalid timer", %{conn: conn} do + # Create item + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) + + conn = + post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["start"]) > 0 + end + + test "a timer with empty body", %{conn: conn} do + # Create item + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) + + conn = + post(conn, Routes.timer_path(conn, :create, item.id, %{})) + + assert conn.status == 200 + end + end + + describe "stop" do + test "timer without any attributes", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = + put( + conn, + Routes.timer_path(conn, :stop, timer.id, %{}) + ) + + assert conn.status == 200 + end + + test "timer that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.timer_path(conn, :stop, -1, %{})) + + assert conn.status == 404 + end + + test "timer that has already stopped", %{conn: conn} do + # Create item and timer + {_item, timer} = item_and_timer_fixture() + + # Stop timer + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + {:ok, timer} = Timer.update_timer(timer, %{stop: now}) + + conn = + put( + conn, + Routes.timer_path(conn, :stop, timer.id, %{}) + ) + + assert conn.status == 403 + end + end + + describe "update" do + test "timer with valid attributes", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = + put( + conn, + Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs) + ) + + assert conn.status == 200 + assert json_response(conn, 200)["start"] == Map.get(@update_attrs, :start) + end + + test "timer with invalid attributes", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = + put( + conn, + Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs) + ) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["start"]) > 0 + end + + test "timer that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.timer_path(conn, :update, -1, -1, @invalid_attrs)) + + assert conn.status == 404 + end + end + + defp item_and_timer_fixture() do + # Create item + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) + + # Create timer + started = NaiveDateTime.utc_now() + {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + + {item, timer} + end +end +``` + +If you run `mix test`, they will fail, +because these functions aren't defined. + +## 2.2 Implementing the controllers + +It's time to implement our sweet controllers! +Let's start with `API.Item`. + +Create file with the path: +`lib/api/item.ex` +and add the following code: + +```elixir +defmodule API.Item do + use AppWeb, :controller + alias App.Item + import Ecto.Changeset + + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + item -> + json(conn, item) + end + + # ID is not an integer + :error -> + errors = %{ + code: 400, + message: "The \'id\' is not an integer." + } + + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + # Attributes to create item + # Person_id will be changed when auth is added + attrs = %{ + text: Map.get(params, "text"), + person_id: 0, + status: 2 + } + + case Item.create_item(attrs) do + # Successfully creates item + {:ok, %{model: item, version: _version}} -> + id_item = Map.take(item, [:id]) + json(conn, id_item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + new_text = Map.get(params, "text") + + # Get item with the ID + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + # If item is found, try to update it + item -> + case Item.update_item(item, %{text: new_text}) do + # Successfully updates item + {:ok, %{model: item, version: _version}} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + end + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request" + } + + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) + Map.put(errors, :errors, changeset_errors) + end +end +``` + +Each function should be self-explanatory. + +- `:show` pertains to `GET api/items/:id` +and returns an `item` object. +- `:create` refers to `POST api/items/:id` +and yields the `id` of the newly created `item` object. +- `:update` refers to `PUT or PATCH api/items/:id` +and returns the updated `item` object. + +Do notice that, since we are using +[`PaperTrail`](https://github.com/izelnakri/paper_trail) +to record changes to the `items`, +database operations return +a map with `"model"` and `"version"`, +hence why we are pattern-matching it when +updating and create items. + +```elixir +{:ok, %{model: item, version: _version}} -> Item.create_item(attrs) +``` + +In cases where, for example, +`:id` is invalid when creating an `item`; +or there are missing fields when creating an `item`, +an error message is displayed in which fields +the changeset validation yielded errors. +To display errors meaningfully, +we *traverse the errors* in the changeset +inside the `make_changeset_errors_readable/1` function. + +For example, +if you try to make a `POST` request +to `api/items` with the following body: + +```json +{ + "invalid": "31231" +} +``` + +The API wil return a `400 Bad Request` HTTP status code +with the following message, +since it was expecting a `text` field: + +```json +{ + "code": 400, + "errors": { + "text": [ + "can't be blank" + ] + }, + "message": "Malformed request" +} +``` + +To retrieve/update/create an `item`, +we are simply calling the schema functions +defined in `lib/app/timer.ex`. + +Create a new file with the path: +`lib/api/timer.ex` +and add the following code: + +```elixir +defmodule API.Timer do + use AppWeb, :controller + alias App.Timer + import Ecto.Changeset + + def index(conn, params) do + item_id = Map.get(params, "item_id") + + timers = Timer.list_timers(item_id) + json(conn, timers) + end + + def stop(conn, params) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + id = Map.get(params, "id") + + # Attributes to update timer + attrs_to_update = %{ + stop: now + } + + # Fetching associated timer + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + json(conn |> put_status(404), errors) + + # If timer is found, try to update it + timer -> + + # If the timer has already stopped, throw error + if not is_nil(timer.stop) do + errors = %{ + code: 403, + message: "Timer with the given \'id\' has already stopped." + } + json(conn |> put_status(403), errors) + + # If timer is ongoing, try to update + else + case Timer.update_timer(timer, attrs_to_update) do + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) + end + end + end + end + + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + timer -> + json(conn, timer) + end + + # ID is not an integer + :error -> + errors = %{ + code: 400, + message: "Timer \'id\' should be an integer." + } + + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + + # Attributes to create timer + attrs = %{ + item_id: Map.get(params, "item_id"), + start: Map.get(params, "start", now), + stop: Map.get(params, "stop") + } + + case Timer.start(attrs) do + # Successfully creates timer + {:ok, timer} -> + id_timer = Map.take(timer, [:id]) + json(conn, id_timer) + + # Error creating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + + # Attributes to update timer + attrs_to_update = %{ + start: Map.get(params, "start"), + stop: Map.get(params, "stop") + } + + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + json(conn |> put_status(404), errors) + + # If timer is found, try to update it + timer -> + case Timer.update_timer(timer, attrs_to_update) do + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) + + + # Error updating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( conn |> put_status(400), errors ) + end + end + end + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request" + } + + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) + Map.put(errors, :errors, changeset_errors) + end +end +``` + +The same set of conditions and operations +occur in `timer_controller.ex`. +The only difference is that there's an extra operation: +the person can list the `timers` for a specific `item`. +For this, we are using a function +that *is not yet implemented* +in `lib/app/timer.ex` - **`list_timers/1`**. + + +# 3. `JSON` serializing + +Let's look at `index` in `lib/app/timer.ex`. +You may notice that we are returning a `JSON` +with `json(conn, timers)`. + +```elixir + def index(conn, params) do + item_id = Map.get(params, "item_id") + + timers = Timer.list_timers(item_id) + json(conn, timers) + end +``` + +However, as it stands, +[`Jason`](https://github.com/michalmuskala/jason) +(which is the package that serializes and deserializes `JSON` objects), +doesn't know how to encode/decode our `timer` and `item` objects. + +We can **derive the implementation** +by specifying which fields should be encoded to `JSON`. +We are going to be using `Jason.Encoder` for this. + +In `lib/app/timer.ex`, +add the line on top of the schema, like so. + +```elixir + @derive {Jason.Encoder, only: [:id, :start, :stop]} + schema "timers" do + field :start, :naive_datetime + field :stop, :naive_datetime + belongs_to :item, Item, references: :id, foreign_key: :item_id + + timestamps() + end +``` + +This will allow `Jason` to encode +any `Timer` struct when returning API calls. + +Let's do the same for `Item`! +In `lib/app/timer.ex`, + +```elixir + @derive {Jason.Encoder, only: [:id, :person_id, :status, :text]} + schema "items" do + field :person_id, :integer + field :status, :integer + field :text, :string + + has_many :timer, Timer + many_to_many(:tags, Tag, join_through: ItemTag, on_replace: :delete) + + timestamps() + end +``` + +By leveraging the `@derive` annotation, +we can easily encode our structs +and serialize them as `JSON` objects +so they can be returned to the person +using the API! ✨ + +# 4. Listing `timers` and `items` and validating updates + +Let's implement `list_timers/1` +in `lib/app/timer.ex`. + +```elixir + def list_timers(item_id) do + Timer + |> where(item_id: ^item_id) + |> order_by(:id) + |> Repo.all() + end +``` + +Simple, isn't it? +We are just retrieving every `timer` object +of a given `item.id`. + +We are also using `Item.get_item/1` +and `Timer.get_timer/1`, +instead of using +[bang (!) functions](https://stackoverflow.com/questions/33324302/what-are-elixir-bang-functions). +We are not using bang functions +because they throw Exceptions. +And using `try/rescue` constructs +[isn't a good practice.](https://elixir-lang.org/getting-started/try-catch-and-rescue.html) + +To validate parameters and return errors, +we need to be able to "catch" these scenarios. +Therefore, we create non-bang functions +that don't raise exceptions. + +In `app/lib/timer.ex`, +add `get_timer/1`. + +```elixir + def get_timer(id), do: Repo.get(Timer, id) +``` + +In `app/lib/item.ex`, +add `get_item/1`. + +```elixir + def get_item(id) do + Item + |> Repo.get(id) + |> Repo.preload(tags: from(t in Tag, order_by: t.text)) + end +``` + +Digressing, +when updating or creating a `timer`, +we want to make sure the `stop` field +is not *before* `start`, +as it simply wouldn't make sense! +To fix this (and give the person using the API +an error explaining in case this happens), +we will create our own +[**changeset datetime validator**](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-validations-and-constraints). + +We will going to validate the two dates +being passed and check if `stop > start`. +Inside `lib/app/timer.ex`, +add the following private function. + +```elixir + defp validate_start_before_stop(changeset) do + start = get_field(changeset, :start) + stop = get_field(changeset, :stop) + + # If start or stop is nil, no comparison occurs. + case is_nil(stop) or is_nil(start) do + true -> changeset + false -> + if NaiveDateTime.compare(start, stop) == :gt do + add_error(changeset, :start, "cannot be later than 'stop'") + else + changeset + end + end + end +``` + +If `stop` or `start` is `nil`, we can't compare the datetimes, +so we just skip the validation. +This usually happens when creating a timer that is ongoing +(`stop` is `nil`). +We won't block creating `timers` with `stop` with a `nil` value. + +Now let's use this validator! +Pipe `start/1` and `update_timer/1` +with our newly created validator, +like so. + +```elixir + def start(attrs \\ %{}) do + %Timer{} + |> changeset(attrs) + |> validate_start_before_stop() + |> Repo.insert() + end + + def update_timer(attrs \\ %{}) do + get_timer!(attrs.id) + |> changeset(attrs) + |> validate_start_before_stop() + |> Repo.update() + end +``` + +If you try to create a `timer` +where `start` is *after* `stop`, +it will error out! + +error_datetimes + +# 5. Error handling in `ErrorView` + +Sometimes the user might access a route that is not defined. +If you are running on localhost and try to access a random route, like: + +```sh +curl -X GET http://localhost:4000/api/items/1/invalidroute -H 'Content-Type: application/json' +``` + +You will receive an `HTML` response. +This `HTML` pertains to the debug screen +you can see on your browser. + +![image](https://user-images.githubusercontent.com/17494745/212749069-82cf85ff-ab6f-4a2f-801c-cae0e9e3229a.png) + +The reason this debug screen is appearing +is because we are running on **`dev` mode**. +If we ran this in production +or *toggle `:debug_errors` to `false` +in `config/dev.exs`, +we would get a simple `"Not Found"` text. + +image + +All of this behaviour occurs +in `lib/app_web/views/error_view.ex`. + +```elixir + def template_not_found(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +``` + +When a browser-based call occurs to an undefined route, +`template` has a value of `404.html`. +Conversely, in our API-scoped routes, +a value of `404.json` is expected. +[Phoenix renders each one according to the `Accept` request header of the incoming request.](https://github.com/phoenixframework/phoenix/issues/1879) + +We should extend this behaviour +for when requests have `Content-type` as `application/json` +to also return a `json` response, +instead of `HTML` (which Phoenix by default does). + +For this, +add the following funciton +inside `lib/app_web/views/error_view.ex`. + +```elixir + def template_not_found(template, %{:conn => conn}) do + acceptHeader = + Enum.at(Plug.Conn.get_req_header(conn, "content-type"), 0, "") + + isJson = + String.contains?(acceptHeader, "application/json") or + String.match?(template, ~r/.*\.json/) + + if isJson do + # If `Content-Type` is `json` but the `Accept` header is not passed, Phoenix considers this as an `.html` request. + # We want to return a JSON, hence why we check if Phoenix considers this an `.html` request. + # + # If so, we return a JSON with appropriate headers. + # We try to return a meaningful error if it exists (:reason). It it doesn't, we return the status message from template + case String.match?(template, ~r/.*\.json/) do + true -> + %{ + error: + Map.get( + conn.assigns.reason, + :message, + Phoenix.Controller.status_message_from_template(template) + ) + } + + false -> + Phoenix.Controller.json( + conn, + %{ + error: + Map.get( + conn.assigns.reason, + :message, + Phoenix.Controller.status_message_from_template(template) + ) + } + ) + end + else + Phoenix.Controller.status_message_from_template(template) + end + end +``` + +In this function, +we are retrieving the `content-type` request header +and asserting if it is `json` or not. +If it does, +we return a `json` response. +Otherwise, we do not. + +Since users sometimes might not send the `accept` request header +but the `content-type` instead, +Phoenix will assume the template is `*.html`-based. +Hence why we are checking for the template +format and returning the response accordingly. + +# 5.1 Fixing tests + +We ought to test these scenarios now! +Open `test/app_web/views/error_view_test.exs` +and add the following piece of code. + +```elixir + + alias Phoenix.ConnTest + + test "testing error view with `Accept` header with `application/json` and passing a `.json` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("accept", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + assert Jason.decode!(render_to_string(AppWeb.ErrorView, "404.json", conn)) == + %{"error" => "Route not found."} + end + + test "testing error view with `Content-type` header with `application/json` and passing a `.json` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("content-type", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + assert Jason.decode!(render_to_string(AppWeb.ErrorView, "404.json", conn)) == + %{"error" => "Route not found."} + end + + test "testing error view with `Content-type` header with `application/json` and passing a `.html` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("content-type", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + resp_body = Map.get(render(AppWeb.ErrorView, "404.html", conn), :resp_body) + + assert Jason.decode!(resp_body) == %{"error" => "Route not found."} + end + + test "testing error view and passing a `.html` template" do + conn = build_conn() + conn = %{conn: conn} + + assert render_to_string(AppWeb.ErrorView, "404.html", conn) == "Not Found" + end +``` + +If we run `mix test`, +you should see the following output! + +```sh +Finished in 1.7 seconds (1.5s async, 0.1s sync) +96 tests, 0 failures +``` + +And our coverage is back to 100%! 🎉 + +# 6. Basic `API` Testing Using `cUrl` + +At this point we have a working `API` for `items` and `timers`. +We can demonstrate it using `curl` commands in the `Terminal`. + +1. Run the `Phoenix` App with the command: `mix s` +2. In a _separate_ `Terminal` window, run the following commands: + +## 6.1 _Create_ an `item` via `API` Request + +```sh +curl -X POST http://localhost:4000/api/items -H 'Content-Type: application/json' -d '{"text":"My Awesome item text"}' +``` +You should expect to see the following result: + +```sh +{"id":1} +``` + +## 6.2 _Read_ the `item` via `API` + +Now if you request this `item` using the `id`: + +```sh +curl http://localhost:4000/api/items/1 +``` + +You should see: + +```sh +{"id":1,"person_id":0,"status":2,"text":"My Awesome item text"} +``` + +This tells us that `items` are being created. ✅ + +## 6.3 Create a `Timer` for your `item` + +The route pattern is: `/api/items/:item_id/timers`. +Therefore our `cURL` request is: + +```sh +curl -X POST http://localhost:4000/api/items/1/timers -H 'Content-Type: application/json' -d '{"start":"2022-10-28T00:00:00"}' +``` + +You should see a response similar to the following: + +```sh +{"id":1} +``` + +This is the `timer.id` and informs us that the timer is running. +You may also create a `timer` without passing a body. +This will create an *ongoing `timer`* +with a `start` value of the current UTC time. + +## 6.4 _Stop_ the `Timer` + +The path to `stop` a timer is `/api/timers/:id`. +Stopping a timer is a simple `PUT` request +without a body. + +```sh +curl -X PUT http://localhost:4000/api/timers/1 -H 'Content-Type: application/json' +``` + +If the timer with the given `id` was not stopped prior, +you should see a response similar to the following: + +```sh +{ + "id": 1, + "start": "2023-01-11T17:40:44", + "stop": "2023-01-17T15:43:24" +} +``` + +Otherwise, an error will surface. + +```sh +{ + "code": 403, + "message": "Timer with the given 'id' has already stopped." +} +``` + +## 6.5 Updating a `Timer` + +You can update a timer with a specific +`stop` and/or `start` attribute. +This can be done in `/api/items/1/timers/1` +with a `PUT` request. + +```sh +curl -X PUT http://localhost:4000/api/items/1/timers/1 -H 'Content-Type: application/json' -d '{"start": "2023-01-11T17:40:44", "stop": "2023-01-11T17:40:45"}' +``` + +If successful, you will see a response like so. + +```sh +{ + "id": 1, + "start": "2023-01-11T17:40:44", + "stop": "2023-01-11T17:40:45" +} +``` + +You might get a `400 - Bad Request` error +if `stop` is before `start` +or the values being passed in the `json` body +are invalid. + +```sh +{ + "code": 400, + "errors": { + "start": [ + "cannot be later than 'stop'" + ] + }, + "message": "Malformed request" +} +``` + +# 7. _Advanced/Automated_ `API` Testing Using `Hoppscotch` + +`API` testing is an essential part +of the development lifecycle. +Incorporating tests will allow us +to avoid regressions +and make sure our `API` performs +the way it's supposed to. +In other words, +the `person` using the API +*expects* consistent responses to their requests. + +Integrating this into a +[CI pipeline](https://en.wikipedia.org/wiki/Continuous_integration) +automates this process +and helps avoiding unintentional breaking changes. + +We are going to be using +[`Hoppscotch`](https://github.com/hoppscotch/hoppscotch). +This is an open source tool +similar to [`Postman`](https://www.postman.com/) +that allow us to make requests, +organize them and create test suites. + +Red more about `Hoppscotch`: +[hoppscotch.io](https://hoppscotch.io) + +## 7.0 `Hoppscotch` Setup + +There is no `App` to download, +but you can run `Hoppscotch` as +an "installable" [`PWA`](https://web.dev/what-are-pwas/): +![hoppscotch-docs-pwa](https://user-images.githubusercontent.com/194400/213877931-47344cfd-4dd7-491e-b032-9e65dff49ebc.png) + +In `Google Chrome` and `Microsoft Edge` +you will see an icon +in the Address bar to +"Install Hoppscotch app": + +image + +That will create what _looks_ like a "Native" App on your `Mac`: + +image + +Which then opens full-screen an _feels_ `Native`: + +Hoppscotch PWA Homescreen + +And you're all set to start testing the `API`. + +> Installing the `PWA` will _significantly_ increase your dev speed +because you can easily ``+`Tab` between your IDE and `Hoppscotch` +and not have to hunt for a Tab in your Web Browser. + +You can use `Hoppscotch` anonymously +(without logging in), +without any loss of functionality. + +If you decide to Authenticate +and you don't want to see the noise in the Top Nav, +simply enable "Zen Mode": + +![hoppscotch-zen-mode](https://user-images.githubusercontent.com/194400/213877013-0ff9c65d-10dc-4741-aa67-395e9fd6adb7.gif) + +With that out of the way, let's get started _using_ `Hoppscotch`! + + +## 7.1 Using `Hoppscotch` + +When you first open `Hoppscotch`, +either in the browser or as a `PWA`, +you will not have anything defined: + +![hoppscotch-empty](https://user-images.githubusercontent.com/194400/213889044-0e38256d-0c59-41f0-bbbe-ee54a16583e2.png) + + +The _first_ thing to do is open an _existing_ collection: + +hoppscotch-open-collection + +Import from hoppscotch: `/lib/api/MVP.json` + +hoppscotch-open-local + +Collection imported: + +image + +_Next_ you'll need to open environment configuration / variables: + +hoppscotch-open-environment + + +![hoppscotch-open-env](https://user-images.githubusercontent.com/194400/213889224-45dd660e-874d-422c-913d-bfdba1052944.png) + +When you click on `Localhost`, you will see an `Edit Environment` Modal: + +image + +**environment variables** +let us switch +between development or production environments seamlessly. + +Even after you have imported the environment configuration file, +it's not automatically selected: + +hoppscotch-environment-not-found + +You need to **_manually_ select `Localhost`**. +With the "Environments" tab selected, click the "Select environment" selector and chose "Localhost": + +hoppscotch-select-environment-localhost + +Once you've selected the `Localhost` environment, the `<>` placeholder will turn from red to blue: + +image + +After importing the collection, +open the `MVP` and `Items` folder, +you will see a list of possible requests. + + +After importing the collection and environment, it _still_ won't work ... +image + +You will see the message: + +**Could not send request**. +Unable to reach the API endpoint. Check your network
connection or select a different interceptor and try again. + + +These are the available options: + +![image](https://user-images.githubusercontent.com/194400/213896782-b96d97a5-5e42-41ec-b299-e64c77246b79.png) + +If you select "Browser extension" it will open the Chrome web store where you can install the extension. + +Install the extension. +Once installed, +add the the `http://localhost:4000` origin: + +add endpoint + +Then the presence of the extension will be visible in the Hoppscotch window/UI: + +![image](https://user-images.githubusercontent.com/194400/213896932-a8f48f2a-f5ee-47c1-aad6-d9a09cf27b48.png) + +image + + +Now you can start testing the requests! +Start the Phoenix server locally +by running `mix s` + +The requests in the collection will _finally_ work: + +![image](https://user-images.githubusercontent.com/194400/213897127-c70a5961-1db6-4d1f-a944-cf08a5bf2f86.png) + + + +If you open `MVP, Items` +and try to `Get Item` (by clicking `Send`), +you will receive a response from the `localhost` server. + +get1 +get2 +get3 + +Depending if the `item` with `id=1` +(which is defined in the *env variable* `item_id` +in the `localhost` environment), +you will receive a successful response +or an error, detailing the error +that the item was not found with the given `id`. + +You can create **tests** for each request, +asserting the response object and HTTP code. +You can do so by clicking the `Tests` tab. + +test + +These tests are important to validate +the expected response of the API. +For further information +on how you can test the response in each request, +please visit their documentation at +https://docsapi.io/features/tests. + +## 7.2 Integration with `Github Actions` with `Hoppscotch CLI` + +These tests can (and should!) +be used in CI pipelines. +To integrate this in our Github Action, +we will need to make some changes to our +[workflow file](https://docs.github.com/en/actions/using-workflows) +in `.github/worflows/ci.yml`. + +We want the [runner](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions#runners) +to be able to *execute* these tests. + +For this, we are going to be using +[**`Hoppscotch CLI`**](https://docs.hoppscotch.io/cli). + +With `hopp` (Hoppscotch CLI), +we will be able to run the collection of requests +and its tests in a command-line environment. + +To run the tests inside a command-line interface, +we are going to need two files: +- **environment file**, +a `json` file with each env variable as key +and its referring value. +For an example, +check the +[`lib/api/localhost.json` file](./lib/api/localhost.json). +- **collection file**, +the `json` file with all the requests. +It is the one you imported earlier. +You can export it the same way you imported it. +For an example, +check the +[`/lib/api/MVP.json` file](./lib/api/MVP.json). + +These files +will need to be pushed into the git repo. +The CI will need access to these files +to run `hopp` commands. + +In the case of our application, +for the tests to run properly, +we need some bootstrap data +so each request runs successfully. +For this, +we also added a +[`api_test_mock_data.sql`](lib/api/api_test_mock_data.sql) +`SQL` script file that will insert some mock data. + +# 7.2.1 Changing the workflow `.yml` file + +It's time to add this API testing step +into our CI workflow! +For this, open `.github/workflows/ci.yml` +and add the following snippet of code +between the `build` and `deploy` jobs. + + +```yml + # API Definition testing + # https://docs.hoppscotch.io/cli + api_definition: + name: API Definition Tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:12 + ports: ['5432:5432'] + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + matrix: + otp: ['25.1.2'] + elixir: ['1.14.2'] + steps: + - uses: actions/checkout@v2 + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - name: Restore deps and _build cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} + - name: Install dependencies + run: mix deps.get + + - name: Install Hoppscotch CLI + run: npm i -g @hoppscotch/cli + + - name: Run mix ecto.create + run: mix ecto.create + + - name: Run ecto.migrate + run: mix ecto.migrate + + - name: Bootstrap Postgres DB with data + run: psql -h localhost -p 5432 -d app_dev -U postgres -f ./lib/api/api_test_mock_data.sql + + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + PGPASSWORD: postgres + + - name: Running server and Hoppscotch Tests + run: mix phx.server & sleep 5 && hopp test -e ./lib/api/envs.json ./lib/api/MVP.json +``` + +Let's breakdown what we just added. +We are running this job in a +[service container](https://docs.github.com/en/actions/using-containerized-services/about-service-containers) +that includes a PostgreSQL database - +similarly to the existent `build` job. + +We then install the `Hoppscotch CLI` +by running `npm i -g @hoppscotch/cli`. + +We then run `mix ecto.create` +and `ecto.migrate` +to create and setup the database. + +After this, +we *boostrap* the database with +`psql -h localhost -p 5432 -d app_dev -U postgres -f ./api/api_test_mock_data.sql`. +This command ([`psql`](https://www.postgresql.org/docs/current/app-psql.html)) +allows us to connect to the PostgreSQL database +and execute the `api_test_mock_data.sql` script, +which inserts data for the tests to run. + + +At last, +we run the API by running `mix phx.server` +and execute `hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json`. +This `hopp` command takes the environment file +and the collections file +and executes its tests. +You might notice we are using `sleep 5`. +This is because we want the `hopp` +command to be executed +after `mix phx.server` finishes initializing. + +And you should be done! +When running `hopp test`, +you will see the result of each request test. + +```sh +↳ API.Item.update/2, at: lib/api/item.ex:65 + 400 : Bad Request (0.049 s) +[info] Sent 400 in 4ms + ✔ Status code is 400 + Ran tests in 0.001 s + +Test Cases: 0 failed 31 passed +Test Suites: 0 failed 28 passed +Test Scripts: 0 failed 22 passed +Tests Duration: 0.041 s +``` + +If one test fails, the whole build fails, as well. + + +# Done! ✅ + +This document is going to be expanded +as development continues. +So if you're reading this, it's because that's all we currently have! + +If you found it interesting, +please let us know by starring the repo on GitHub! ⭐ + +
+ +[![HitCount](https://hits.dwyl.com/dwyl/app-mvp-api.svg)](https://hits.dwyl.com/dwyl/app-mvp) \ No newline at end of file diff --git a/api.md b/api.md index fdf7753d..8a7557e1 100644 --- a/api.md +++ b/api.md @@ -1,34 +1,61 @@
-# `REST`ful API integration +# `API` Integration
This guide demonstrates -how to *extend* our MVP `Phoenix` application -so it also acts as an **API** +how to *extend* our MVP `Phoenix` App +so it also acts as an **`API`** returning `JSON` data. -We want our users to securely query -and manipulate their data -and want to ensure all actions -that are performed in the Web API -can also be done through our `RESTful` API +`people` want to securely query +and update their data. +We want to ensure all actions +that are performed in the Web UI +can also be done through our `REST API` *and* `WebSocket API` (for all real-time updates). -Let's get cracking! 🎉 -## 1. Add `/api` scope and pipeline +
-We want all our API requests -to be made under the `/api` route. -This is easier for us to manage changes to API -that don't create unnecessary regressions to our liveviews. +- [`API` Integration](#api-integration) +- [1. Add `/api` scope and pipeline in `router.ex`](#1-add-api-scope-and-pipeline-in-routerex) +- [2. `API.Item` and `API.Timer`](#2-apiitem-and-apitimer) + - [2.1 Adding tests](#21-adding-tests) + - [2.2 Implementing the controllers](#22-implementing-the-controllers) +- [3. `JSON` serializing](#3-json-serializing) +- [4. Listing `timers` and `items` and validating updates](#4-listing-timers-and-items-and-validating-updates) +- [5. Error handling in `ErrorView`](#5-error-handling-in-errorview) +- [5.1 Fixing tests](#51-fixing-tests) +- [6. Basic `API` Testing Using `cUrl`](#6-basic-api-testing-using-curl) + - [6.1 _Create_ an `item` via `API` Request](#61-create-an-item-via-api-request) + - [6.2 _Read_ the `item` via `API`](#62-read-the-item-via-api) + - [6.3 Create a `Timer` for your `item`](#63-create-a-timer-for-your-item) + - [6.4 _Stop_ the `Timer`](#64-stop-the-timer) + - [6.5 Updating a `Timer`](#65-updating-a-timer) +- [7. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#7-advancedautomated-api-testing-using-hoppscotch) + - [7.0 `Hoppscotch` Setup](#70-hoppscotch-setup) + - [7.1 Using `Hoppscotch`](#71-using-hoppscotch) + - [7.2 Integration with `Github Actions` with `Hoppscotch CLI`](#72-integration-with-github-actions-with-hoppscotch-cli) +- [7.2.1 Changing the workflow `.yml` file](#721-changing-the-workflow-yml-file) +- [Done! ✅](#done-) + + +
+ + +# 1. Add `/api` scope and pipeline in `router.ex` + +We want all `API` requests +to be made under the `/api` namespace. +This is easier for us to manage changes to `API` +that don't create unnecessary complexity in the `LiveView` code. Let's start by opening `lib/router.ex` and create a new `:api` pipeline -to be used under `scope "/api"`. +to be used under `scope "/api"`: ```elixir @@ -46,22 +73,24 @@ to be used under `scope "/api"`. resources "/items", API.ItemController, only: [:create, :update, :show] resources "/items/:item_id/timers", API.TimerController, only: [:create, :update, :show, :index] + + put "/timers/:id", Timer, :stop end ``` We are creating an `:api` pipeline -that will only accepts and returns `json` objects. +that will only accept and return `json` objects. `:fetch_session` is added as a plug because `:authOptional` requires us to do so. Every request that is routed to `/api` -will be piped through both `:api` and `:authOptional` pipelines. +will be piped through both the `:api` and `:authOptional` pipelines. You might have noticed two new controllers: `API.ItemController` and `API.TimerController`. We are going to need to create these to handle our requests! -## 2. `ItemController` and `TimerController` +# 2. `API.Item` and `API.Timer` Before creating our controller, let's define our requirements. We want the API to: @@ -69,39 +98,45 @@ Before creating our controller, let's define our requirements. We want the API t - list `timers` of an `item` - create an `item` and return only the created `id`. - edit an `item` +- stop a `timer` +- update a `timer` +- create a `timer` We want each endpoint to respond appropriately if any data is invalid, -the response body and status should inform the user what went wrong. +the response body and status should inform the `person` what went wrong. We can leverage changesets to validate the `item` and `timer` and check if it's correctly formatted. -### 2.1 Adding tests +## 2.1 Adding tests Let's approach this with a [`TDD mindset`](https://github.com/dwyl/learn-tdd) and create our tests first! Create two new files: -- `test/app_web/api/item_controller_test.exs` -- `test/app_web/api/timer_controller_test.exs` +- `test/api/item_test.exs` +- `test/api/timer_test.exs` Before implementing, -we recommend giving a look at [`learn-api-design`](https://github.com/dwyl/learn-api-design), -we are going to be using some tips coming from there! +we recommend giving a look at +[`learn-api-design`](https://github.com/dwyl/learn-api-design), +we are going to be using some best practices described there! -We want the API requests to be handled gracefully +We want the `API` requests +to be handled gracefully when an error occurs. -The person using the API -[**should be shown meaningful errors**](https://github.com/dwyl/learn-api-design/blob/revamp/README.md#show-meaningful-errors). +The `person` using the `API` +[**should be shown _meaningful_ errors**](https://github.com/dwyl/learn-api-design/blob/main/README.md#show-meaningful-errors). Therefore, we need to test how our API behaves when invalid attributes are requested and/or an error occurs **and where**. -Let's start with `item_controller_test.exs`. +Open `test/api/item_test.exs` +and add the following code: ```elixir -defmodule AppWeb.API.ItemControllerTest do - use AppWeb.ConnCase +defmodule API.ItemTest do +use AppWeb.ConnCase alias App.Item @create_attrs %{person_id: 42, status: 0, text: "some text"} @@ -113,7 +148,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = get(conn, Routes.item_path(conn, :show, item.id)) - assert conn.status == 200 assert json_response(conn, 200)["id"] == item.id assert json_response(conn, 200)["text"] == item.text end @@ -134,7 +168,6 @@ defmodule AppWeb.API.ItemControllerTest do test "a valid item", %{conn: conn} do conn = post(conn, Routes.item_path(conn, :create, @create_attrs)) - assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") assert json_response(conn, 200)["status"] == @@ -147,7 +180,6 @@ defmodule AppWeb.API.ItemControllerTest do test "an invalid item", %{conn: conn} do conn = post(conn, Routes.item_path(conn, :create, @invalid_attrs)) - assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end end @@ -157,7 +189,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) - assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) end @@ -165,14 +196,20 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) - assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end + + test "item that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.item_path(conn, :update, -1, @invalid_attrs)) + + assert conn.status == 404 + end end end ``` -In `/item`, users will be able to +In `/item`, +a `person` will be able to **create**, **update** or **query a single item**. In each test we are testing successful scenarios (the [Happy Path](https://en.wikipedia.org/wiki/Happy_path)), @@ -180,10 +217,10 @@ alongside situations where the person requests non-existent items or tries to create new ones with invalid attributes. -The same scenario occurs in `test/app_web/api/timer_controller_test.exs`. +Next, in the `test/api/timer_test.exs` file: ```elixir -defmodule AppWeb.API.TimerControllerTest do +defmodule API.TimerTest do use AppWeb.ConnCase alias App.Timer alias App.Item @@ -197,7 +234,7 @@ defmodule AppWeb.API.TimerControllerTest do describe "index" do test "timers", %{conn: conn} do # Create item and timer - {item, timer} = item_and_timer_fixture() + {item, _timer} = item_and_timer_fixture() conn = get(conn, Routes.timer_path(conn, :index, item.id)) @@ -219,7 +256,8 @@ defmodule AppWeb.API.TimerControllerTest do test "not found timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) @@ -228,7 +266,8 @@ defmodule AppWeb.API.TimerControllerTest do test "invalid id (not being an integer)", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) assert conn.status == 400 @@ -238,7 +277,8 @@ defmodule AppWeb.API.TimerControllerTest do describe "create" do test "a valid timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) # Create timer conn = @@ -252,7 +292,8 @@ defmodule AppWeb.API.TimerControllerTest do test "an invalid timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) @@ -260,6 +301,55 @@ defmodule AppWeb.API.TimerControllerTest do assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 end + + test "a timer with empty body", %{conn: conn} do + # Create item + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) + + conn = + post(conn, Routes.timer_path(conn, :create, item.id, %{})) + + assert conn.status == 200 + end + end + + describe "stop" do + test "timer without any attributes", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = + put( + conn, + Routes.timer_path(conn, :stop, timer.id, %{}) + ) + + assert conn.status == 200 + end + + test "timer that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.timer_path(conn, :stop, -1, %{})) + + assert conn.status == 404 + end + + test "timer that has already stopped", %{conn: conn} do + # Create item and timer + {_item, timer} = item_and_timer_fixture() + + # Stop timer + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + {:ok, timer} = Timer.update_timer(timer, %{stop: now}) + + conn = + put( + conn, + Routes.timer_path(conn, :stop, timer.id, %{}) + ) + + assert conn.status == 403 + end end describe "update" do @@ -290,11 +380,18 @@ defmodule AppWeb.API.TimerControllerTest do assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 end + + test "timer that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.timer_path(conn, :update, -1, -1, @invalid_attrs)) + + assert conn.status == 404 + end end defp item_and_timer_fixture() do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) # Create timer started = NaiveDateTime.utc_now() @@ -308,17 +405,17 @@ end If you run `mix test`, they will fail, because these functions aren't defined. -### 2.2 Implementing the controllers +## 2.2 Implementing the controllers It's time to implement our sweet controllers! -Let's start with `ItemController`. +Let's start with `API.Item`. -Create a directory inside `lib/app_web/controllers/api` -and a file inside called `item_controller.ex`. -Paste the following code. +Create file with the path: +`lib/api/item.ex` +and add the following code: ```elixir -defmodule AppWeb.API.ItemController do +defmodule API.Item do use AppWeb, :controller alias App.Item import Ecto.Changeset @@ -381,21 +478,32 @@ defmodule AppWeb.API.ItemController do id = Map.get(params, "id") new_text = Map.get(params, "text") - item = Item.get_item!(id) + # Get item with the ID + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } + + json(conn |> put_status(404), errors) - case Item.update_item(item, %{text: new_text}) do - # Successfully updates item - {:ok, %{model: item, version: _version}} -> - json(conn, item) + # If item is found, try to update it + item -> + case Item.update_item(item, %{text: new_text}) do + # Successfully updates item + {:ok, %{model: item, version: _version}} -> + json(conn, item) - # Error creating item - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) - json( - conn |> put_status(400), - errors - ) + json( + conn |> put_status(400), + errors + ) + end end end @@ -471,12 +579,12 @@ To retrieve/update/create an `item`, we are simply calling the schema functions defined in `lib/app/timer.ex`. -Let's head over and create our `TimerController`! -Inside the same directory, create `timer_controller.ex` -and use this code. +Create a new file with the path: +`lib/api/timer.ex` +and add the following code: ```elixir -defmodule AppWeb.API.TimerController do +defmodule API.Timer do use AppWeb, :controller alias App.Timer import Ecto.Changeset @@ -488,6 +596,46 @@ defmodule AppWeb.API.TimerController do json(conn, timers) end + def stop(conn, params) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + id = Map.get(params, "id") + + # Attributes to update timer + attrs_to_update = %{ + stop: now + } + + # Fetching associated timer + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + json(conn |> put_status(404), errors) + + # If timer is found, try to update it + timer -> + + # If the timer has already stopped, throw error + if not is_nil(timer.stop) do + errors = %{ + code: 403, + message: "Timer with the given \'id\' has already stopped." + } + json(conn |> put_status(403), errors) + + # If timer is ongoing, try to update + else + case Timer.update_timer(timer, attrs_to_update) do + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) + end + end + end + end + def show(conn, %{"id" => id} = _params) do case Integer.parse(id) do # ID is an integer @@ -498,6 +646,7 @@ defmodule AppWeb.API.TimerController do code: 404, message: "No timer found with the given \'id\'." } + json(conn |> put_status(404), errors) timer -> @@ -508,7 +657,7 @@ defmodule AppWeb.API.TimerController do :error -> errors = %{ code: 400, - message: "The \'id\' is not an integer." + message: "Timer \'id\' should be an integer." } json(conn |> put_status(400), errors) @@ -516,21 +665,22 @@ defmodule AppWeb.API.TimerController do end def create(conn, params) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() + # Attributes to create timer attrs = %{ item_id: Map.get(params, "item_id"), - start: Map.get(params, "start"), + start: Map.get(params, "start", now), stop: Map.get(params, "stop") } case Timer.start(attrs) do - - # Successfully creates item + # Successfully creates timer {:ok, timer} -> id_timer = Map.take(timer, [:id]) json(conn, id_timer) - # Error creating item + # Error creating timer {:error, %Ecto.Changeset{} = changeset} -> errors = make_changeset_errors_readable(changeset) @@ -542,35 +692,43 @@ defmodule AppWeb.API.TimerController do end def update(conn, params) do + id = Map.get(params, "id") + # Attributes to update timer attrs_to_update = %{ - id: Map.get(params, "id"), start: Map.get(params, "start"), stop: Map.get(params, "stop") } - case Timer.update_timer(attrs_to_update) do + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + json(conn |> put_status(404), errors) - # Successfully updates timer - {:ok, timer} -> - json(conn, timer) + # If timer is found, try to update it + timer -> + case Timer.update_timer(timer, attrs_to_update) do + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) - # Error creating timer - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) - json( - conn |> put_status(400), - errors - ) + # Error updating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( conn |> put_status(400), errors ) + end end end - defp make_changeset_errors_readable(changeset) do errors = %{ code: 400, - message: "Malformed request", + message: "Malformed request" } changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) @@ -588,7 +746,7 @@ that *is not yet implemented* in `lib/app/timer.ex` - **`list_timers/1`**. -## 3. `JSON` serializing +# 3. `JSON` serializing Let's look at `index` in `lib/app/timer.ex`. You may notice that we are returning a `JSON` @@ -652,7 +810,7 @@ and serialize them as `JSON` objects so they can be returned to the person using the API! ✨ -## 4. Listing `timers` and `items` and validating updates +# 4. Listing `timers` and `items` and validating updates Let's implement `list_timers/1` in `lib/app/timer.ex`. @@ -768,12 +926,676 @@ it will error out! error_datetimes +# 5. Error handling in `ErrorView` + +Sometimes the user might access a route that is not defined. +If you are running on localhost and try to access a random route, like: + +```sh +curl -X GET http://localhost:4000/api/items/1/invalidroute -H 'Content-Type: application/json' +``` + +You will receive an `HTML` response. +This `HTML` pertains to the debug screen +you can see on your browser. + +![image](https://user-images.githubusercontent.com/17494745/212749069-82cf85ff-ab6f-4a2f-801c-cae0e9e3229a.png) + +The reason this debug screen is appearing +is because we are running on **`dev` mode**. +If we ran this in production +or *toggle `:debug_errors` to `false` +in `config/dev.exs`, +we would get a simple `"Not Found"` text. + +image + +All of this behaviour occurs +in `lib/app_web/views/error_view.ex`. + +```elixir + def template_not_found(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +``` + +When a browser-based call occurs to an undefined route, +`template` has a value of `404.html`. +Conversely, in our API-scoped routes, +a value of `404.json` is expected. +[Phoenix renders each one according to the `Accept` request header of the incoming request.](https://github.com/phoenixframework/phoenix/issues/1879) + +We should extend this behaviour +for when requests have `Content-type` as `application/json` +to also return a `json` response, +instead of `HTML` (which Phoenix by default does). + +For this, +add the following funciton +inside `lib/app_web/views/error_view.ex`. + +```elixir + def template_not_found(template, %{:conn => conn}) do + acceptHeader = + Enum.at(Plug.Conn.get_req_header(conn, "content-type"), 0, "") + + isJson = + String.contains?(acceptHeader, "application/json") or + String.match?(template, ~r/.*\.json/) + + if isJson do + # If `Content-Type` is `json` but the `Accept` header is not passed, Phoenix considers this as an `.html` request. + # We want to return a JSON, hence why we check if Phoenix considers this an `.html` request. + # + # If so, we return a JSON with appropriate headers. + # We try to return a meaningful error if it exists (:reason). It it doesn't, we return the status message from template + case String.match?(template, ~r/.*\.json/) do + true -> + %{ + error: + Map.get( + conn.assigns.reason, + :message, + Phoenix.Controller.status_message_from_template(template) + ) + } + + false -> + Phoenix.Controller.json( + conn, + %{ + error: + Map.get( + conn.assigns.reason, + :message, + Phoenix.Controller.status_message_from_template(template) + ) + } + ) + end + else + Phoenix.Controller.status_message_from_template(template) + end + end +``` + +In this function, +we are retrieving the `content-type` request header +and asserting if it is `json` or not. +If it does, +we return a `json` response. +Otherwise, we do not. + +Since users sometimes might not send the `accept` request header +but the `content-type` instead, +Phoenix will assume the template is `*.html`-based. +Hence why we are checking for the template +format and returning the response accordingly. + +# 5.1 Fixing tests + +We ought to test these scenarios now! +Open `test/app_web/views/error_view_test.exs` +and add the following piece of code. + +```elixir + + alias Phoenix.ConnTest + + test "testing error view with `Accept` header with `application/json` and passing a `.json` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("accept", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + assert Jason.decode!(render_to_string(AppWeb.ErrorView, "404.json", conn)) == + %{"error" => "Route not found."} + end + + test "testing error view with `Content-type` header with `application/json` and passing a `.json` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("content-type", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + assert Jason.decode!(render_to_string(AppWeb.ErrorView, "404.json", conn)) == + %{"error" => "Route not found."} + end + + test "testing error view with `Content-type` header with `application/json` and passing a `.html` template" do + assigns = %{reason: %{message: "Route not found."}} + + conn = + build_conn() + |> put_req_header("content-type", "application/json") + |> Map.put(:assigns, assigns) + + conn = %{conn: conn} + + resp_body = Map.get(render(AppWeb.ErrorView, "404.html", conn), :resp_body) + + assert Jason.decode!(resp_body) == %{"error" => "Route not found."} + end + + test "testing error view and passing a `.html` template" do + conn = build_conn() + conn = %{conn: conn} + + assert render_to_string(AppWeb.ErrorView, "404.html", conn) == "Not Found" + end +``` + +If we run `mix test`, +you should see the following output! + +```sh +Finished in 1.7 seconds (1.5s async, 0.1s sync) +96 tests, 0 failures +``` + +And our coverage is back to 100%! 🎉 + +# 6. Basic `API` Testing Using `cUrl` + +At this point we have a working `API` for `items` and `timers`. +We can demonstrate it using `curl` commands in the `Terminal`. + +1. Run the `Phoenix` App with the command: `mix s` +2. In a _separate_ `Terminal` window, run the following commands: + +## 6.1 _Create_ an `item` via `API` Request + +```sh +curl -X POST http://localhost:4000/api/items -H 'Content-Type: application/json' -d '{"text":"My Awesome item text"}' +``` +You should expect to see the following result: + +```sh +{"id":1} +``` + +## 6.2 _Read_ the `item` via `API` + +Now if you request this `item` using the `id`: + +```sh +curl http://localhost:4000/api/items/1 +``` + +You should see: + +```sh +{"id":1,"person_id":0,"status":2,"text":"My Awesome item text"} +``` + +This tells us that `items` are being created. ✅ + +## 6.3 Create a `Timer` for your `item` + +The route pattern is: `/api/items/:item_id/timers`. +Therefore our `cURL` request is: + +```sh +curl -X POST http://localhost:4000/api/items/1/timers -H 'Content-Type: application/json' -d '{"start":"2022-10-28T00:00:00"}' +``` + +You should see a response similar to the following: + +```sh +{"id":1} +``` + +This is the `timer.id` and informs us that the timer is running. +You may also create a `timer` without passing a body. +This will create an *ongoing `timer`* +with a `start` value of the current UTC time. + +## 6.4 _Stop_ the `Timer` + +The path to `stop` a timer is `/api/timers/:id`. +Stopping a timer is a simple `PUT` request +without a body. + +```sh +curl -X PUT http://localhost:4000/api/timers/1 -H 'Content-Type: application/json' +``` + +If the timer with the given `id` was not stopped prior, +you should see a response similar to the following: + +```sh +{ + "id": 1, + "start": "2023-01-11T17:40:44", + "stop": "2023-01-17T15:43:24" +} +``` + +Otherwise, an error will surface. + +```sh +{ + "code": 403, + "message": "Timer with the given 'id' has already stopped." +} +``` + +## 6.5 Updating a `Timer` + +You can update a timer with a specific +`stop` and/or `start` attribute. +This can be done in `/api/items/1/timers/1` +with a `PUT` request. + +```sh +curl -X PUT http://localhost:4000/api/items/1/timers/1 -H 'Content-Type: application/json' -d '{"start": "2023-01-11T17:40:44", "stop": "2023-01-11T17:40:45"}' +``` + +If successful, you will see a response like so. + +```sh +{ + "id": 1, + "start": "2023-01-11T17:40:44", + "stop": "2023-01-11T17:40:45" +} +``` + +You might get a `400 - Bad Request` error +if `stop` is before `start` +or the values being passed in the `json` body +are invalid. + +```sh +{ + "code": 400, + "errors": { + "start": [ + "cannot be later than 'stop'" + ] + }, + "message": "Malformed request" +} +``` + +# 7. _Advanced/Automated_ `API` Testing Using `Hoppscotch` + +`API` testing is an essential part +of the development lifecycle. +Incorporating tests will allow us +to avoid regressions +and make sure our `API` performs +the way it's supposed to. +In other words, +the `person` using the API +*expects* consistent responses to their requests. + +Integrating this into a +[CI pipeline](https://en.wikipedia.org/wiki/Continuous_integration) +automates this process +and helps avoiding unintentional breaking changes. + +We are going to be using +[`Hoppscotch`](https://github.com/hoppscotch/hoppscotch). +This is an open source tool +similar to [`Postman`](https://www.postman.com/) +that allow us to make requests, +organize them and create test suites. + +Red more about `Hoppscotch`: +[hoppscotch.io](https://hoppscotch.io) + +## 7.0 `Hoppscotch` Setup + +There is no `App` to download, +but you can run `Hoppscotch` as +an "installable" [`PWA`](https://web.dev/what-are-pwas/): +![hoppscotch-docs-pwa](https://user-images.githubusercontent.com/194400/213877931-47344cfd-4dd7-491e-b032-9e65dff49ebc.png) -# And you should be done! +In `Google Chrome` and `Microsoft Edge` +you will see an icon +in the Address bar to +"Install Hoppscotch app": + +image + +That will create what _looks_ like a "Native" App on your `Mac`: + +image + +Which then opens full-screen an _feels_ `Native`: + +Hoppscotch PWA Homescreen + +And you're all set to start testing the `API`. + +> Installing the `PWA` will _significantly_ increase your dev speed +because you can easily ``+`Tab` between your IDE and `Hoppscotch` +and not have to hunt for a Tab in your Web Browser. + +You can use `Hoppscotch` anonymously +(without logging in), +without any loss of functionality. + +If you decide to Authenticate +and you don't want to see the noise in the Top Nav, +simply enable "Zen Mode": + +![hoppscotch-zen-mode](https://user-images.githubusercontent.com/194400/213877013-0ff9c65d-10dc-4741-aa67-395e9fd6adb7.gif) + +With that out of the way, let's get started _using_ `Hoppscotch`! + + +## 7.1 Using `Hoppscotch` + +When you first open `Hoppscotch`, +either in the browser or as a `PWA`, +you will not have anything defined: + +![hoppscotch-empty](https://user-images.githubusercontent.com/194400/213889044-0e38256d-0c59-41f0-bbbe-ee54a16583e2.png) + + +The _first_ thing to do is open an _existing_ collection: + +hoppscotch-open-collection + +Import from hoppscotch: `/lib/api/MVP.json` + +hoppscotch-open-local + +Collection imported: + +image + +_Next_ you'll need to open environment configuration / variables: + +hoppscotch-open-environment + + +![hoppscotch-open-env](https://user-images.githubusercontent.com/194400/213889224-45dd660e-874d-422c-913d-bfdba1052944.png) + +When you click on `Localhost`, you will see an `Edit Environment` Modal: + +image + +**environment variables** +let us switch +between development or production environments seamlessly. + +Even after you have imported the environment configuration file, +it's not automatically selected: + +hoppscotch-environment-not-found + +You need to **_manually_ select `Localhost`**. +With the "Environments" tab selected, click the "Select environment" selector and chose "Localhost": + +hoppscotch-select-environment-localhost + +Once you've selected the `Localhost` environment, the `<>` placeholder will turn from red to blue: + +image + +After importing the collection, +open the `MVP` and `Items` folder, +you will see a list of possible requests. + + +After importing the collection and environment, it _still_ won't work ... +image + +You will see the message: + +**Could not send request**. +Unable to reach the API endpoint. Check your network
connection or select a different interceptor and try again. + + +These are the available options: + +![image](https://user-images.githubusercontent.com/194400/213896782-b96d97a5-5e42-41ec-b299-e64c77246b79.png) + +If you select "Browser extension" it will open the Chrome web store where you can install the extension. + +Install the extension. +Once installed, +add the the `http://localhost:4000` origin: + +add endpoint + +Then the presence of the extension will be visible in the Hoppscotch window/UI: + +![image](https://user-images.githubusercontent.com/194400/213896932-a8f48f2a-f5ee-47c1-aad6-d9a09cf27b48.png) + +image + + +Now you can start testing the requests! +Start the Phoenix server locally +by running `mix s` + +The requests in the collection will _finally_ work: + +![image](https://user-images.githubusercontent.com/194400/213897127-c70a5961-1db6-4d1f-a944-cf08a5bf2f86.png) + + + +If you open `MVP, Items` +and try to `Get Item` (by clicking `Send`), +you will receive a response from the `localhost` server. + +get1 +get2 +get3 + +Depending if the `item` with `id=1` +(which is defined in the *env variable* `item_id` +in the `localhost` environment), +you will receive a successful response +or an error, detailing the error +that the item was not found with the given `id`. + +You can create **tests** for each request, +asserting the response object and HTTP code. +You can do so by clicking the `Tests` tab. + +test + +These tests are important to validate +the expected response of the API. +For further information +on how you can test the response in each request, +please visit their documentation at +https://docsapi.io/features/tests. + +## 7.2 Integration with `Github Actions` with `Hoppscotch CLI` + +These tests can (and should!) +be used in CI pipelines. +To integrate this in our Github Action, +we will need to make some changes to our +[workflow file](https://docs.github.com/en/actions/using-workflows) +in `.github/worflows/ci.yml`. + +We want the [runner](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions#runners) +to be able to *execute* these tests. + +For this, we are going to be using +[**`Hoppscotch CLI`**](https://docs.hoppscotch.io/cli). + +With `hopp` (Hoppscotch CLI), +we will be able to run the collection of requests +and its tests in a command-line environment. + +To run the tests inside a command-line interface, +we are going to need two files: +- **environment file**, +a `json` file with each env variable as key +and its referring value. +For an example, +check the +[`lib/api/localhost.json` file](./lib/api/localhost.json). +- **collection file**, +the `json` file with all the requests. +It is the one you imported earlier. +You can export it the same way you imported it. +For an example, +check the +[`/lib/api/MVP.json` file](./lib/api/MVP.json). + +These files +will need to be pushed into the git repo. +The CI will need access to these files +to run `hopp` commands. + +In the case of our application, +for the tests to run properly, +we need some bootstrap data +so each request runs successfully. +For this, +we also added a +[`api_test_mock_data.sql`](lib/api/api_test_mock_data.sql) +`SQL` script file that will insert some mock data. + +# 7.2.1 Changing the workflow `.yml` file + +It's time to add this API testing step +into our CI workflow! +For this, open `.github/workflows/ci.yml` +and add the following snippet of code +between the `build` and `deploy` jobs. + + +```yml + # API Definition testing + # https://docs.hoppscotch.io/cli + api_definition: + name: API Definition Tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:12 + ports: ['5432:5432'] + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + matrix: + otp: ['25.1.2'] + elixir: ['1.14.2'] + steps: + - uses: actions/checkout@v2 + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - name: Restore deps and _build cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} + - name: Install dependencies + run: mix deps.get + + - name: Install Hoppscotch CLI + run: npm i -g @hoppscotch/cli + + - name: Run mix ecto.create + run: mix ecto.create + + - name: Run ecto.migrate + run: mix ecto.migrate + + - name: Bootstrap Postgres DB with data + run: psql -h localhost -p 5432 -d app_dev -U postgres -f ./lib/api/api_test_mock_data.sql + + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + PGPASSWORD: postgres + + - name: Running server and Hoppscotch Tests + run: mix phx.server & sleep 5 && hopp test -e ./lib/api/envs.json ./lib/api/MVP.json +``` + +Let's breakdown what we just added. +We are running this job in a +[service container](https://docs.github.com/en/actions/using-containerized-services/about-service-containers) +that includes a PostgreSQL database - +similarly to the existent `build` job. + +We then install the `Hoppscotch CLI` +by running `npm i -g @hoppscotch/cli`. + +We then run `mix ecto.create` +and `ecto.migrate` +to create and setup the database. + +After this, +we *boostrap* the database with +`psql -h localhost -p 5432 -d app_dev -U postgres -f ./api/api_test_mock_data.sql`. +This command ([`psql`](https://www.postgresql.org/docs/current/app-psql.html)) +allows us to connect to the PostgreSQL database +and execute the `api_test_mock_data.sql` script, +which inserts data for the tests to run. + + +At last, +we run the API by running `mix phx.server` +and execute `hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json`. +This `hopp` command takes the environment file +and the collections file +and executes its tests. +You might notice we are using `sleep 5`. +This is because we want the `hopp` +command to be executed +after `mix phx.server` finishes initializing. + +And you should be done! +When running `hopp test`, +you will see the result of each request test. + +```sh +↳ API.Item.update/2, at: lib/api/item.ex:65 + 400 : Bad Request (0.049 s) +[info] Sent 400 in 4ms + ✔ Status code is 400 + Ran tests in 0.001 s + +Test Cases: 0 failed 31 passed +Test Suites: 0 failed 28 passed +Test Scripts: 0 failed 22 passed +Tests Duration: 0.041 s +``` + +If one test fails, the whole build fails, as well. + + +# Done! ✅ This document is going to be expanded as development continues. So if you're reading this, it's because that's all we currently have! If you found it interesting, -please let us know by starring the repo on GitHub! ⭐ \ No newline at end of file +please let us know by starring the repo on GitHub! ⭐ + +
+ +[![HitCount](https://hits.dwyl.com/dwyl/app-mvp-api.svg)](https://hits.dwyl.com/dwyl/app-mvp) \ No newline at end of file diff --git a/config/test.exs b/config/test.exs index 65e6f8c5..a7275cd0 100644 --- a/config/test.exs +++ b/config/test.exs @@ -22,7 +22,7 @@ config :app, AppWeb.Endpoint, server: false # Print only warnings and errors during test -config :logger, level: :debug +config :logger, level: :info # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime diff --git a/lib/api/MVP.json b/lib/api/MVP.json new file mode 100644 index 00000000..577b22bb --- /dev/null +++ b/lib/api/MVP.json @@ -0,0 +1,368 @@ +{ + "folders": [ + { + "requests": [ + { + "v": "1", + "endpoint": "<>/api/items/<>", + "name": "Get item", + "params": [], + "headers": [ + { "active": true, "value": "application/json", "key": "accept" } + ], + "method": "GET", + "auth": { + "authType": "none", + "addTo": "Headers", + "authActive": true, + "value": "", + "key": "" + }, + "preRequestScript": "", + "testScript": "// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});", + "body": { "contentType": null, "body": null } + }, + { + "v": "1", + "endpoint": "<>/api/items/<>", + "name": "Get item (404 - Item not found)", + "params": [], + "headers": [ + { "key": "accept", "value": "application/json", "active": true } + ], + "method": "GET", + "auth": { "authType": "none", "authActive": true }, + "preRequestScript": "", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "body": { "body": null, "contentType": null } + }, + { + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "auth": { "authActive": true, "authType": "none" }, + "preRequestScript": "", + "params": [], + "body": { "body": null, "contentType": null }, + "name": "Get item (400 - Invalid ID)", + "v": "1", + "endpoint": "<>/api/items/<>", + "method": "GET", + "headers": [ + { "key": "accept", "active": true, "value": "application/json" } + ] + }, + { + "v": "1", + "endpoint": "<>/api/items", + "name": "Create item", + "params": [], + "headers": [ + { "key": "accept", "active": true, "value": "application/json" } + ], + "method": "POST", + "auth": { "authType": "none", "authActive": true }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "body": { + "body": "{\n \"text\": \"some text\"\n}", + "contentType": "application/json" + } + }, + { + "name": "Create item (400 - Invalid attributes)", + "endpoint": "<>/api/items", + "method": "POST", + "auth": { "authActive": true, "authType": "none" }, + "params": [], + "preRequestScript": "", + "v": "1", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"invalid\": \"something\"\n}" + }, + "headers": [ + { "value": "application/json", "active": true, "key": "accept" } + ] + }, + { + "v": "1", + "endpoint": "<>/api/items/<>", + "name": "Update item", + "params": [], + "headers": [ + { "active": true, "value": "application/json", "key": "accept" } + ], + "method": "PUT", + "auth": { "authActive": true, "authType": "none" }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});", + "body": { + "body": "{\n \"text\": \"new updated text\"\n}", + "contentType": "application/json" + } + }, + { + "preRequestScript": "", + "params": [], + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"new updated text\"\n}" + }, + "v": "1", + "name": "Update item (404 - Item not found)", + "headers": [ + { "active": true, "value": "application/json", "key": "accept" } + ], + "method": "PUT", + "auth": { "authType": "none", "authActive": true }, + "endpoint": "<>/api/items/<>", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});" + }, + { + "v": "1", + "method": "PUT", + "body": { + "contentType": "application/json", + "body": "{\n \"invalid\": \"invalid\"\n}" + }, + "endpoint": "<>/api/items/<>", + "headers": [ + { "active": true, "key": "accept", "value": "application/json" } + ], + "params": [], + "preRequestScript": "", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "name": "Update item (400 - Invalid attributes)", + "auth": { "authActive": true, "authType": "none" } + } + ], + "name": "Items", + "v": 1, + "folders": [] + }, + { + "v": 1, + "folders": [], + "name": "Timers", + "requests": [ + { + "body": { "body": null, "contentType": null }, + "method": "GET", + "name": "Get timers", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", + "preRequestScript": "", + "v": "1", + "auth": { "authType": "none", "authActive": true }, + "params": [], + "headers": [ + { "key": "accept", "active": true, "value": "application/json" } + ], + "endpoint": "<>/api/items/<>/timers" + }, + { + "auth": { "authActive": true, "authType": "none" }, + "headers": [ + { "key": "accept", "active": true, "value": "application/json" } + ], + "endpoint": "<>/api/items/<>/timers/<>", + "preRequestScript": "", + "name": "Get timer", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", + "body": { "contentType": null, "body": null }, + "params": [], + "v": "1", + "method": "GET" + }, + { + "headers": [ + { "active": true, "value": "application/json", "key": "accept" } + ], + "method": "GET", + "name": "Get timer (404 - Timer not found)", + "auth": { "authType": "none", "authActive": true }, + "params": [], + "v": "1", + "body": { "contentType": null, "body": null }, + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "preRequestScript": "", + "endpoint": "<>/api/items/<>/timers/<>" + }, + { + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "endpoint": "<>/api/items/<>/timers/<>", + "v": "1", + "method": "GET", + "body": { "contentType": null, "body": null }, + "auth": { "authType": "none", "authActive": true }, + "params": [], + "preRequestScript": "", + "headers": [ + { "value": "application/json", "key": "accept", "active": true } + ], + "name": "Get timer (400 - Invalid ID)" + }, + { + "endpoint": "<>/api/items/<>/timers", + "headers": [ + { "key": "accept", "value": "application/json", "active": true } + ], + "body": { + "body": "{\n \"start\": \"2023-01-11T17:40:44\"\n}", + "contentType": "application/json" + }, + "preRequestScript": "", + "v": "1", + "name": "Create timer (custom start)", + "auth": { "authActive": true, "authType": "none" }, + "method": "POST", + "params": [], + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});" + }, + { + "auth": { "authActive": true, "authType": "none" }, + "endpoint": "<>/api/items/<>/timers", + "v": "1", + "method": "POST", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "params": [], + "name": "Create timer (no start)", + "preRequestScript": "", + "headers": [ + { "key": "accept", "active": true, "value": "application/json" } + ], + "body": { "contentType": null, "body": null } + }, + { + "headers": [ + { "key": "accept", "value": "application/json", "active": true } + ], + "endpoint": "<>/api/items/<>/timers", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "params": [], + "v": "1", + "body": { + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}", + "contentType": "application/json" + }, + "name": "Create timer (400 - Stop is after start) ", + "method": "POST", + "auth": { "authType": "none", "authActive": true }, + "preRequestScript": "" + }, + { + "name": "Create timer (400 - Invalid date format) ", + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-invalid-01\"\n}" + }, + "headers": [ + { "active": true, "value": "application/json", "key": "accept" } + ], + "params": [], + "method": "POST", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "v": "1", + "endpoint": "<>/api/items/<>/timers", + "preRequestScript": "", + "auth": { "authType": "none", "authActive": true } + }, + { + "v": "1", + "endpoint": "<>/api/items/<>/timers/<>", + "name": "Update timer", + "params": [], + "headers": [ + { "active": true, "key": "accept", "value": "application/json" } + ], + "method": "PUT", + "auth": { "authType": "none", "authActive": true }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}" + } + }, + { + "preRequestScript": "", + "endpoint": "<>/api/items/<>/timers/<>", + "params": [], + "v": "1", + "headers": [ + { "active": true, "value": "application/json", "key": "accept" } + ], + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "body": { + "body": "{\n \"start\": \"2023-invalid-01\"\n}", + "contentType": "application/json" + }, + "name": "Update timer (400 - Invalid date format)", + "method": "PUT", + "auth": { "authType": "none", "authActive": true } + }, + { + "preRequestScript": "", + "endpoint": "<>/api/items/<>/timers/<>", + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}" + }, + "auth": { "authActive": true, "authType": "none" }, + "headers": [ + { "key": "accept", "active": true, "value": "application/json" } + ], + "v": "1", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "name": "Update timer (400 - Stop is after start)", + "params": [], + "method": "PUT" + }, + { + "name": "Stop timer", + "method": "PUT", + "auth": { "authType": "none", "authActive": true }, + "params": [], + "v": "1", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", + "headers": [ + { "key": "accept", "value": "application/json", "active": true } + ], + "body": { "body": null, "contentType": null }, + "preRequestScript": "", + "endpoint": "<>/api/timers/<>" + }, + { + "preRequestScript": "", + "headers": [ + { "active": true, "key": "accept", "value": "application/json" } + ], + "endpoint": "<>/api/timers/<>", + "body": { "body": null, "contentType": null }, + "method": "PUT", + "name": "Stop timer (403 - Timer has already been stopped)", + "v": "1", + "params": [], + "testScript": "\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});", + "auth": { "authType": "none", "authActive": true } + }, + { + "preRequestScript": "", + "body": { "contentType": null, "body": null }, + "v": "1", + "method": "PUT", + "name": "Stop timer (404 - Timer not found)", + "endpoint": "<>/api/timers/<>", + "auth": { "authActive": true, "authType": "none" }, + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "headers": [ + { "value": "application/json", "key": "accept", "active": true } + ], + "params": [] + } + ] + } + ], + "v": 1, + "name": "MVP", + "requests": [] +} diff --git a/lib/api/api_test_mock_data.sql b/lib/api/api_test_mock_data.sql new file mode 100644 index 00000000..fd97f63d --- /dev/null +++ b/lib/api/api_test_mock_data.sql @@ -0,0 +1,10 @@ +-- Adding single mock item +INSERT INTO public.items +("text", person_id, status, inserted_at, updated_at) +VALUES('random text', 0, 2, '2023-01-19 13:48:12.000', '2023-01-19 13:48:12.000'); + +-- Adding two timers +INSERT INTO public.timers +("start", stop, item_id, inserted_at, updated_at) +VALUES('2023-01-19 15:52:00', '2023-01-19 15:52:03', 1, '2023-01-19 15:52:03.000', '2023-01-19 15:52:03.000'), +('2023-01-19 15:55:00', null, 1, '2023-01-19 15:52:03.000', '2023-01-19 15:52:03.000'); diff --git a/lib/api/envs.json b/lib/api/envs.json new file mode 100644 index 00000000..4259a7c0 --- /dev/null +++ b/lib/api/envs.json @@ -0,0 +1,35 @@ +[ + { + "name": "Localhost", + "variables": [ + { + "key": "host", + "value": "http://localhost:4000" + }, + { + "value": "1", + "key": "item_id" + }, + { + "value": "-1", + "key": "notfound_item_id" + }, + { + "value": "invalid_id", + "key": "invalid_id" + }, + { + "value": "1", + "key": "timer_id" + }, + { + "value": "-1", + "key": "notfound_timer_id" + }, + { + "value": "2", + "key": "timer_id_to_stop" + } + ] + } +] \ No newline at end of file diff --git a/lib/api/localhost.json b/lib/api/localhost.json new file mode 100644 index 00000000..6daedd25 --- /dev/null +++ b/lib/api/localhost.json @@ -0,0 +1,9 @@ +{ + "host": "http://localhost:4000", + "item_id": "1", + "notfound_item_id": "-1", + "invalid_id": "invalid_id", + "timer_id": "1", + "notfound_timer_id": "-1", + "timer_id_to_stop": 2 +} \ No newline at end of file diff --git a/lib/app/timer.ex b/lib/app/timer.ex index 87a16a4e..51d812f3 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -424,7 +424,7 @@ defmodule App.Timer do """ def stop_timer_for_item_id(item_id) when is_nil(item_id) do - Logger.debug( + Logger.info( "stop_timer_for_item_id/1 called without item_id: #{item_id} fail." ) end @@ -440,7 +440,7 @@ defmodule App.Timer do if res.num_rows > 0 do timer_id = res.rows |> List.first() |> List.first() - Logger.debug( + Logger.info( "Found timer.id: #{timer_id} for item: #{item_id}, attempting to stop." ) diff --git a/lib/app_web/live/app_live.html.heex b/lib/app_web/live/app_live.html.heex index 212f1dd1..44e52d22 100644 --- a/lib/app_web/live/app_live.html.heex +++ b/lib/app_web/live/app_live.html.heex @@ -18,7 +18,7 @@ id="textval" phx-change="validate" phx-debounce="2000" - placeholder="What needs to be done?!!!" + placeholder="Capture what is on your mind ..." required="required" x-data="{resize() { $el.style.height = '100px'; diff --git a/test/api/timer_test.exs b/test/api/timer_test.exs index a028e82c..706c36d2 100644 --- a/test/api/timer_test.exs +++ b/test/api/timer_test.exs @@ -95,6 +95,7 @@ defmodule API.TimerTest do test "timer without any attributes", %{conn: conn} do # Create item and timer {item, timer} = item_and_timer_fixture() + {_item, timer} = item_and_timer_fixture() conn = put( diff --git a/test/app_web/live/app_live_test.exs b/test/app_web/live/app_live_test.exs index 4a4856e1..09db5d57 100644 --- a/test/app_web/live/app_live_test.exs +++ b/test/app_web/live/app_live_test.exs @@ -6,8 +6,8 @@ defmodule AppWeb.AppLiveTest do test "disconnected and connected render", %{conn: conn} do {:ok, page_live, disconnected_html} = live(conn, "/") - assert disconnected_html =~ "done" - assert render(page_live) =~ "done" + assert disconnected_html =~ "mind" + assert render(page_live) =~ "mind" end test "connect and create an item", %{conn: conn} do @@ -510,6 +510,15 @@ defmodule AppWeb.AppLiveTest do assert AppWeb.AppLive.timer_text(timer) == "00:00:42" end + test "timer_text(start, stop) both the same" do + timer = %{ + start: ~N[2022-07-17 09:01:42.000000], + stop: ~N[2022-07-17 09:01:42.000000] + } + + assert AppWeb.AppLive.timer_text(timer) == "00:00:00" + end + test "timer_text(start, stop)" do timer = %{ start: ~N[2022-07-17 09:01:42.000000], From e353ef5028ca5f94ee03115e118fcc0ef12ed066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 11:22:51 +0000 Subject: [PATCH 05/34] fix: Removing warnings. #256 --- test/api/tag_test.exs | 2 +- test/api/timer_test.exs | 1 - test/app_web/views/error_view_test.exs | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/test/api/tag_test.exs b/test/api/tag_test.exs index 763d54ca..d760cc0b 100644 --- a/test/api/tag_test.exs +++ b/test/api/tag_test.exs @@ -69,7 +69,7 @@ defmodule API.TagTest do end test "tag that doesn't exist", %{conn: conn} do - {:ok, tag} = Tag.create_tag(@create_attrs) + {:ok, _tag} = Tag.create_tag(@create_attrs) conn = put(conn, Routes.api_tag_path(conn, :update, -1, @update_attrs)) assert conn.status == 404 diff --git a/test/api/timer_test.exs b/test/api/timer_test.exs index 706c36d2..99b0a1b8 100644 --- a/test/api/timer_test.exs +++ b/test/api/timer_test.exs @@ -94,7 +94,6 @@ defmodule API.TimerTest do describe "stop" do test "timer without any attributes", %{conn: conn} do # Create item and timer - {item, timer} = item_and_timer_fixture() {_item, timer} = item_and_timer_fixture() conn = diff --git a/test/app_web/views/error_view_test.exs b/test/app_web/views/error_view_test.exs index bd1b4e05..f9f00002 100644 --- a/test/app_web/views/error_view_test.exs +++ b/test/app_web/views/error_view_test.exs @@ -1,5 +1,4 @@ defmodule AppWeb.ErrorViewTest do - alias Phoenix.ConnTest use AppWeb.ConnCase, async: true # Bring render/3 and render_to_string/3 for testing custom views From 62b580f33a860186972bccaaceb773096e49271c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 13:33:52 +0000 Subject: [PATCH 06/34] merge: Merge main to branch. --- .github/workflows/ci.yml | 16 +- API.md | 1601 -------------------------------------- priv/repo/seeds.exs | 21 + 3 files changed, 26 insertions(+), 1612 deletions(-) delete mode 100644 API.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 833233b8..f8cdd33f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,18 +98,12 @@ jobs: - name: Install Hoppscotch CLI run: npm i -g @hoppscotch/cli - - name: Run mix ecto.create - run: mix ecto.create - - - name: Run ecto.migrate - run: mix ecto.migrate - - - name: Bootstrap Postgres DB with data - run: psql -h localhost -p 5432 -d app_dev -U postgres -f ./lib/api/api_test_mock_data.sql + # Setups database and adds seed data for API definition tests + - name: Run mix setup + run: mix ecto.setup env: - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - PGPASSWORD: postgres + MIX_ENV: dev + AUTH_API_KEY: ${{ secrets.AUTH_API_KEY }} - name: Running server and Hoppscotch Tests run: mix phx.server & sleep 5 && hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json diff --git a/API.md b/API.md deleted file mode 100644 index 8a7557e1..00000000 --- a/API.md +++ /dev/null @@ -1,1601 +0,0 @@ -
- -# `API` Integration - -
- -This guide demonstrates -how to *extend* our MVP `Phoenix` App -so it also acts as an **`API`** -returning `JSON` data. - -`people` want to securely query -and update their data. -We want to ensure all actions -that are performed in the Web UI -can also be done through our `REST API` -*and* `WebSocket API` -(for all real-time updates). - - -
- -- [`API` Integration](#api-integration) -- [1. Add `/api` scope and pipeline in `router.ex`](#1-add-api-scope-and-pipeline-in-routerex) -- [2. `API.Item` and `API.Timer`](#2-apiitem-and-apitimer) - - [2.1 Adding tests](#21-adding-tests) - - [2.2 Implementing the controllers](#22-implementing-the-controllers) -- [3. `JSON` serializing](#3-json-serializing) -- [4. Listing `timers` and `items` and validating updates](#4-listing-timers-and-items-and-validating-updates) -- [5. Error handling in `ErrorView`](#5-error-handling-in-errorview) -- [5.1 Fixing tests](#51-fixing-tests) -- [6. Basic `API` Testing Using `cUrl`](#6-basic-api-testing-using-curl) - - [6.1 _Create_ an `item` via `API` Request](#61-create-an-item-via-api-request) - - [6.2 _Read_ the `item` via `API`](#62-read-the-item-via-api) - - [6.3 Create a `Timer` for your `item`](#63-create-a-timer-for-your-item) - - [6.4 _Stop_ the `Timer`](#64-stop-the-timer) - - [6.5 Updating a `Timer`](#65-updating-a-timer) -- [7. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#7-advancedautomated-api-testing-using-hoppscotch) - - [7.0 `Hoppscotch` Setup](#70-hoppscotch-setup) - - [7.1 Using `Hoppscotch`](#71-using-hoppscotch) - - [7.2 Integration with `Github Actions` with `Hoppscotch CLI`](#72-integration-with-github-actions-with-hoppscotch-cli) -- [7.2.1 Changing the workflow `.yml` file](#721-changing-the-workflow-yml-file) -- [Done! ✅](#done-) - - -
- - -# 1. Add `/api` scope and pipeline in `router.ex` - -We want all `API` requests -to be made under the `/api` namespace. -This is easier for us to manage changes to `API` -that don't create unnecessary complexity in the `LiveView` code. - -Let's start by opening `lib/router.ex` -and create a new `:api` pipeline -to be used under `scope "/api"`: - -```elixir - - pipeline :api do - plug :accepts, ["json"] - plug :fetch_session - end - - pipeline :authOptional do - plug(AuthPlugOptional) - end - - scope "/api", AppWeb do - pipe_through [:api, :authOptional] - - resources "/items", API.ItemController, only: [:create, :update, :show] - resources "/items/:item_id/timers", API.TimerController, only: [:create, :update, :show, :index] - - put "/timers/:id", Timer, :stop - end -``` - -We are creating an `:api` pipeline -that will only accept and return `json` objects. -`:fetch_session` is added as a plug -because `:authOptional` requires us to do so. - -Every request that is routed to `/api` -will be piped through both the `:api` and `:authOptional` pipelines. - -You might have noticed two new controllers: -`API.ItemController` and `API.TimerController`. -We are going to need to create these to handle our requests! - -# 2. `API.Item` and `API.Timer` - -Before creating our controller, let's define our requirements. We want the API to: - -- read contents of an `item`/`timer` -- list `timers` of an `item` -- create an `item` and return only the created `id`. -- edit an `item` -- stop a `timer` -- update a `timer` -- create a `timer` - -We want each endpoint to respond appropriately if any data is invalid, -the response body and status should inform the `person` what went wrong. -We can leverage changesets to validate the `item` and `timer` -and check if it's correctly formatted. - -## 2.1 Adding tests - -Let's approach this -with a [`TDD mindset`](https://github.com/dwyl/learn-tdd) -and create our tests first! - -Create two new files: -- `test/api/item_test.exs` -- `test/api/timer_test.exs` - -Before implementing, -we recommend giving a look at -[`learn-api-design`](https://github.com/dwyl/learn-api-design), -we are going to be using some best practices described there! - -We want the `API` requests -to be handled gracefully -when an error occurs. -The `person` using the `API` -[**should be shown _meaningful_ errors**](https://github.com/dwyl/learn-api-design/blob/main/README.md#show-meaningful-errors). -Therefore, we need to test how our API behaves -when invalid attributes are requested -and/or an error occurs **and where**. - -Open `test/api/item_test.exs` -and add the following code: - -```elixir -defmodule API.ItemTest do -use AppWeb.ConnCase - alias App.Item - - @create_attrs %{person_id: 42, status: 0, text: "some text"} - @update_attrs %{person_id: 43, status: 0, text: "some updated text"} - @invalid_attrs %{person_id: nil, status: nil, text: nil} - - describe "show" do - test "specific item", %{conn: conn} do - {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = get(conn, Routes.item_path(conn, :show, item.id)) - - assert json_response(conn, 200)["id"] == item.id - assert json_response(conn, 200)["text"] == item.text - end - - test "not found item", %{conn: conn} do - conn = get(conn, Routes.item_path(conn, :show, -1)) - - assert conn.status == 404 - end - - test "invalid id (not being an integer)", %{conn: conn} do - conn = get(conn, Routes.item_path(conn, :show, "invalid")) - assert conn.status == 400 - end - end - - describe "create" do - test "a valid item", %{conn: conn} do - conn = post(conn, Routes.item_path(conn, :create, @create_attrs)) - - assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") - - assert json_response(conn, 200)["status"] == - Map.get(@create_attrs, "status") - - assert json_response(conn, 200)["person_id"] == - Map.get(@create_attrs, "person_id") - end - - test "an invalid item", %{conn: conn} do - conn = post(conn, Routes.item_path(conn, :create, @invalid_attrs)) - - assert length(json_response(conn, 400)["errors"]["text"]) > 0 - end - end - - describe "update" do - test "item with valid attributes", %{conn: conn} do - {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) - - assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) - end - - test "item with invalid attributes", %{conn: conn} do - {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) - - assert length(json_response(conn, 400)["errors"]["text"]) > 0 - end - - test "item that doesn't exist", %{conn: conn} do - conn = put(conn, Routes.item_path(conn, :update, -1, @invalid_attrs)) - - assert conn.status == 404 - end - end -end -``` - -In `/item`, -a `person` will be able to -**create**, **update** or **query a single item**. -In each test we are testing -successful scenarios (the [Happy Path](https://en.wikipedia.org/wiki/Happy_path)), -alongside situations where the person -requests non-existent items -or tries to create new ones with invalid attributes. - -Next, in the `test/api/timer_test.exs` file: - -```elixir -defmodule API.TimerTest do - use AppWeb.ConnCase - alias App.Timer - alias App.Item - - @create_item_attrs %{person_id: 42, status: 0, text: "some text"} - - @create_attrs %{item_id: 42, start: "2022-10-27T00:00:00"} - @update_attrs %{item_id: 43, start: "2022-10-28T00:00:00"} - @invalid_attrs %{item_id: nil, start: nil} - - describe "index" do - test "timers", %{conn: conn} do - # Create item and timer - {item, _timer} = item_and_timer_fixture() - - conn = get(conn, Routes.timer_path(conn, :index, item.id)) - - assert conn.status == 200 - assert length(json_response(conn, 200)) == 1 - end - end - - describe "show" do - test "specific timer", %{conn: conn} do - # Create item and timer - {item, timer} = item_and_timer_fixture() - - conn = get(conn, Routes.timer_path(conn, :show, item.id, timer.id)) - - assert conn.status == 200 - assert json_response(conn, 200)["id"] == timer.id - end - - test "not found timer", %{conn: conn} do - # Create item - {:ok, %{model: item, version: _version}} = - Item.create_item(@create_item_attrs) - - conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) - - assert conn.status == 404 - end - - test "invalid id (not being an integer)", %{conn: conn} do - # Create item - {:ok, %{model: item, version: _version}} = - Item.create_item(@create_item_attrs) - - conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) - assert conn.status == 400 - end - end - - describe "create" do - test "a valid timer", %{conn: conn} do - # Create item - {:ok, %{model: item, version: _version}} = - Item.create_item(@create_item_attrs) - - # Create timer - conn = - post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) - - assert conn.status == 200 - - assert json_response(conn, 200)["start"] == - Map.get(@create_attrs, "start") - end - - test "an invalid timer", %{conn: conn} do - # Create item - {:ok, %{model: item, version: _version}} = - Item.create_item(@create_item_attrs) - - conn = - post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) - - assert conn.status == 400 - assert length(json_response(conn, 400)["errors"]["start"]) > 0 - end - - test "a timer with empty body", %{conn: conn} do - # Create item - {:ok, %{model: item, version: _version}} = - Item.create_item(@create_item_attrs) - - conn = - post(conn, Routes.timer_path(conn, :create, item.id, %{})) - - assert conn.status == 200 - end - end - - describe "stop" do - test "timer without any attributes", %{conn: conn} do - # Create item and timer - {item, timer} = item_and_timer_fixture() - - conn = - put( - conn, - Routes.timer_path(conn, :stop, timer.id, %{}) - ) - - assert conn.status == 200 - end - - test "timer that doesn't exist", %{conn: conn} do - conn = put(conn, Routes.timer_path(conn, :stop, -1, %{})) - - assert conn.status == 404 - end - - test "timer that has already stopped", %{conn: conn} do - # Create item and timer - {_item, timer} = item_and_timer_fixture() - - # Stop timer - now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() - {:ok, timer} = Timer.update_timer(timer, %{stop: now}) - - conn = - put( - conn, - Routes.timer_path(conn, :stop, timer.id, %{}) - ) - - assert conn.status == 403 - end - end - - describe "update" do - test "timer with valid attributes", %{conn: conn} do - # Create item and timer - {item, timer} = item_and_timer_fixture() - - conn = - put( - conn, - Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs) - ) - - assert conn.status == 200 - assert json_response(conn, 200)["start"] == Map.get(@update_attrs, :start) - end - - test "timer with invalid attributes", %{conn: conn} do - # Create item and timer - {item, timer} = item_and_timer_fixture() - - conn = - put( - conn, - Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs) - ) - - assert conn.status == 400 - assert length(json_response(conn, 400)["errors"]["start"]) > 0 - end - - test "timer that doesn't exist", %{conn: conn} do - conn = put(conn, Routes.timer_path(conn, :update, -1, -1, @invalid_attrs)) - - assert conn.status == 404 - end - end - - defp item_and_timer_fixture() do - # Create item - {:ok, %{model: item, version: _version}} = - Item.create_item(@create_item_attrs) - - # Create timer - started = NaiveDateTime.utc_now() - {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) - - {item, timer} - end -end -``` - -If you run `mix test`, they will fail, -because these functions aren't defined. - -## 2.2 Implementing the controllers - -It's time to implement our sweet controllers! -Let's start with `API.Item`. - -Create file with the path: -`lib/api/item.ex` -and add the following code: - -```elixir -defmodule API.Item do - use AppWeb, :controller - alias App.Item - import Ecto.Changeset - - def show(conn, %{"id" => id} = _params) do - case Integer.parse(id) do - # ID is an integer - {id, _float} -> - case Item.get_item(id) do - nil -> - errors = %{ - code: 404, - message: "No item found with the given \'id\'." - } - - json(conn |> put_status(404), errors) - - item -> - json(conn, item) - end - - # ID is not an integer - :error -> - errors = %{ - code: 400, - message: "The \'id\' is not an integer." - } - - json(conn |> put_status(400), errors) - end - end - - def create(conn, params) do - # Attributes to create item - # Person_id will be changed when auth is added - attrs = %{ - text: Map.get(params, "text"), - person_id: 0, - status: 2 - } - - case Item.create_item(attrs) do - # Successfully creates item - {:ok, %{model: item, version: _version}} -> - id_item = Map.take(item, [:id]) - json(conn, id_item) - - # Error creating item - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) - - json( - conn |> put_status(400), - errors - ) - end - end - - def update(conn, params) do - id = Map.get(params, "id") - new_text = Map.get(params, "text") - - # Get item with the ID - case Item.get_item(id) do - nil -> - errors = %{ - code: 404, - message: "No item found with the given \'id\'." - } - - json(conn |> put_status(404), errors) - - # If item is found, try to update it - item -> - case Item.update_item(item, %{text: new_text}) do - # Successfully updates item - {:ok, %{model: item, version: _version}} -> - json(conn, item) - - # Error creating item - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) - - json( - conn |> put_status(400), - errors - ) - end - end - end - - defp make_changeset_errors_readable(changeset) do - errors = %{ - code: 400, - message: "Malformed request" - } - - changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) - Map.put(errors, :errors, changeset_errors) - end -end -``` - -Each function should be self-explanatory. - -- `:show` pertains to `GET api/items/:id` -and returns an `item` object. -- `:create` refers to `POST api/items/:id` -and yields the `id` of the newly created `item` object. -- `:update` refers to `PUT or PATCH api/items/:id` -and returns the updated `item` object. - -Do notice that, since we are using -[`PaperTrail`](https://github.com/izelnakri/paper_trail) -to record changes to the `items`, -database operations return -a map with `"model"` and `"version"`, -hence why we are pattern-matching it when -updating and create items. - -```elixir -{:ok, %{model: item, version: _version}} -> Item.create_item(attrs) -``` - -In cases where, for example, -`:id` is invalid when creating an `item`; -or there are missing fields when creating an `item`, -an error message is displayed in which fields -the changeset validation yielded errors. -To display errors meaningfully, -we *traverse the errors* in the changeset -inside the `make_changeset_errors_readable/1` function. - -For example, -if you try to make a `POST` request -to `api/items` with the following body: - -```json -{ - "invalid": "31231" -} -``` - -The API wil return a `400 Bad Request` HTTP status code -with the following message, -since it was expecting a `text` field: - -```json -{ - "code": 400, - "errors": { - "text": [ - "can't be blank" - ] - }, - "message": "Malformed request" -} -``` - -To retrieve/update/create an `item`, -we are simply calling the schema functions -defined in `lib/app/timer.ex`. - -Create a new file with the path: -`lib/api/timer.ex` -and add the following code: - -```elixir -defmodule API.Timer do - use AppWeb, :controller - alias App.Timer - import Ecto.Changeset - - def index(conn, params) do - item_id = Map.get(params, "item_id") - - timers = Timer.list_timers(item_id) - json(conn, timers) - end - - def stop(conn, params) do - now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() - id = Map.get(params, "id") - - # Attributes to update timer - attrs_to_update = %{ - stop: now - } - - # Fetching associated timer - case Timer.get_timer(id) do - nil -> - errors = %{ - code: 404, - message: "No timer found with the given \'id\'." - } - json(conn |> put_status(404), errors) - - # If timer is found, try to update it - timer -> - - # If the timer has already stopped, throw error - if not is_nil(timer.stop) do - errors = %{ - code: 403, - message: "Timer with the given \'id\' has already stopped." - } - json(conn |> put_status(403), errors) - - # If timer is ongoing, try to update - else - case Timer.update_timer(timer, attrs_to_update) do - # Successfully updates timer - {:ok, timer} -> - json(conn, timer) - end - end - end - end - - def show(conn, %{"id" => id} = _params) do - case Integer.parse(id) do - # ID is an integer - {id, _float} -> - case Timer.get_timer(id) do - nil -> - errors = %{ - code: 404, - message: "No timer found with the given \'id\'." - } - - json(conn |> put_status(404), errors) - - timer -> - json(conn, timer) - end - - # ID is not an integer - :error -> - errors = %{ - code: 400, - message: "Timer \'id\' should be an integer." - } - - json(conn |> put_status(400), errors) - end - end - - def create(conn, params) do - now = NaiveDateTime.utc_now() |> NaiveDateTime.to_iso8601() - - # Attributes to create timer - attrs = %{ - item_id: Map.get(params, "item_id"), - start: Map.get(params, "start", now), - stop: Map.get(params, "stop") - } - - case Timer.start(attrs) do - # Successfully creates timer - {:ok, timer} -> - id_timer = Map.take(timer, [:id]) - json(conn, id_timer) - - # Error creating timer - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) - - json( - conn |> put_status(400), - errors - ) - end - end - - def update(conn, params) do - id = Map.get(params, "id") - - # Attributes to update timer - attrs_to_update = %{ - start: Map.get(params, "start"), - stop: Map.get(params, "stop") - } - - case Timer.get_timer(id) do - nil -> - errors = %{ - code: 404, - message: "No timer found with the given \'id\'." - } - json(conn |> put_status(404), errors) - - # If timer is found, try to update it - timer -> - case Timer.update_timer(timer, attrs_to_update) do - # Successfully updates timer - {:ok, timer} -> - json(conn, timer) - - - # Error updating timer - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) - - json( conn |> put_status(400), errors ) - end - end - end - - defp make_changeset_errors_readable(changeset) do - errors = %{ - code: 400, - message: "Malformed request" - } - - changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) - Map.put(errors, :errors, changeset_errors) - end -end -``` - -The same set of conditions and operations -occur in `timer_controller.ex`. -The only difference is that there's an extra operation: -the person can list the `timers` for a specific `item`. -For this, we are using a function -that *is not yet implemented* -in `lib/app/timer.ex` - **`list_timers/1`**. - - -# 3. `JSON` serializing - -Let's look at `index` in `lib/app/timer.ex`. -You may notice that we are returning a `JSON` -with `json(conn, timers)`. - -```elixir - def index(conn, params) do - item_id = Map.get(params, "item_id") - - timers = Timer.list_timers(item_id) - json(conn, timers) - end -``` - -However, as it stands, -[`Jason`](https://github.com/michalmuskala/jason) -(which is the package that serializes and deserializes `JSON` objects), -doesn't know how to encode/decode our `timer` and `item` objects. - -We can **derive the implementation** -by specifying which fields should be encoded to `JSON`. -We are going to be using `Jason.Encoder` for this. - -In `lib/app/timer.ex`, -add the line on top of the schema, like so. - -```elixir - @derive {Jason.Encoder, only: [:id, :start, :stop]} - schema "timers" do - field :start, :naive_datetime - field :stop, :naive_datetime - belongs_to :item, Item, references: :id, foreign_key: :item_id - - timestamps() - end -``` - -This will allow `Jason` to encode -any `Timer` struct when returning API calls. - -Let's do the same for `Item`! -In `lib/app/timer.ex`, - -```elixir - @derive {Jason.Encoder, only: [:id, :person_id, :status, :text]} - schema "items" do - field :person_id, :integer - field :status, :integer - field :text, :string - - has_many :timer, Timer - many_to_many(:tags, Tag, join_through: ItemTag, on_replace: :delete) - - timestamps() - end -``` - -By leveraging the `@derive` annotation, -we can easily encode our structs -and serialize them as `JSON` objects -so they can be returned to the person -using the API! ✨ - -# 4. Listing `timers` and `items` and validating updates - -Let's implement `list_timers/1` -in `lib/app/timer.ex`. - -```elixir - def list_timers(item_id) do - Timer - |> where(item_id: ^item_id) - |> order_by(:id) - |> Repo.all() - end -``` - -Simple, isn't it? -We are just retrieving every `timer` object -of a given `item.id`. - -We are also using `Item.get_item/1` -and `Timer.get_timer/1`, -instead of using -[bang (!) functions](https://stackoverflow.com/questions/33324302/what-are-elixir-bang-functions). -We are not using bang functions -because they throw Exceptions. -And using `try/rescue` constructs -[isn't a good practice.](https://elixir-lang.org/getting-started/try-catch-and-rescue.html) - -To validate parameters and return errors, -we need to be able to "catch" these scenarios. -Therefore, we create non-bang functions -that don't raise exceptions. - -In `app/lib/timer.ex`, -add `get_timer/1`. - -```elixir - def get_timer(id), do: Repo.get(Timer, id) -``` - -In `app/lib/item.ex`, -add `get_item/1`. - -```elixir - def get_item(id) do - Item - |> Repo.get(id) - |> Repo.preload(tags: from(t in Tag, order_by: t.text)) - end -``` - -Digressing, -when updating or creating a `timer`, -we want to make sure the `stop` field -is not *before* `start`, -as it simply wouldn't make sense! -To fix this (and give the person using the API -an error explaining in case this happens), -we will create our own -[**changeset datetime validator**](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-validations-and-constraints). - -We will going to validate the two dates -being passed and check if `stop > start`. -Inside `lib/app/timer.ex`, -add the following private function. - -```elixir - defp validate_start_before_stop(changeset) do - start = get_field(changeset, :start) - stop = get_field(changeset, :stop) - - # If start or stop is nil, no comparison occurs. - case is_nil(stop) or is_nil(start) do - true -> changeset - false -> - if NaiveDateTime.compare(start, stop) == :gt do - add_error(changeset, :start, "cannot be later than 'stop'") - else - changeset - end - end - end -``` - -If `stop` or `start` is `nil`, we can't compare the datetimes, -so we just skip the validation. -This usually happens when creating a timer that is ongoing -(`stop` is `nil`). -We won't block creating `timers` with `stop` with a `nil` value. - -Now let's use this validator! -Pipe `start/1` and `update_timer/1` -with our newly created validator, -like so. - -```elixir - def start(attrs \\ %{}) do - %Timer{} - |> changeset(attrs) - |> validate_start_before_stop() - |> Repo.insert() - end - - def update_timer(attrs \\ %{}) do - get_timer!(attrs.id) - |> changeset(attrs) - |> validate_start_before_stop() - |> Repo.update() - end -``` - -If you try to create a `timer` -where `start` is *after* `stop`, -it will error out! - -error_datetimes - -# 5. Error handling in `ErrorView` - -Sometimes the user might access a route that is not defined. -If you are running on localhost and try to access a random route, like: - -```sh -curl -X GET http://localhost:4000/api/items/1/invalidroute -H 'Content-Type: application/json' -``` - -You will receive an `HTML` response. -This `HTML` pertains to the debug screen -you can see on your browser. - -![image](https://user-images.githubusercontent.com/17494745/212749069-82cf85ff-ab6f-4a2f-801c-cae0e9e3229a.png) - -The reason this debug screen is appearing -is because we are running on **`dev` mode**. -If we ran this in production -or *toggle `:debug_errors` to `false` -in `config/dev.exs`, -we would get a simple `"Not Found"` text. - -image - -All of this behaviour occurs -in `lib/app_web/views/error_view.ex`. - -```elixir - def template_not_found(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) - end -``` - -When a browser-based call occurs to an undefined route, -`template` has a value of `404.html`. -Conversely, in our API-scoped routes, -a value of `404.json` is expected. -[Phoenix renders each one according to the `Accept` request header of the incoming request.](https://github.com/phoenixframework/phoenix/issues/1879) - -We should extend this behaviour -for when requests have `Content-type` as `application/json` -to also return a `json` response, -instead of `HTML` (which Phoenix by default does). - -For this, -add the following funciton -inside `lib/app_web/views/error_view.ex`. - -```elixir - def template_not_found(template, %{:conn => conn}) do - acceptHeader = - Enum.at(Plug.Conn.get_req_header(conn, "content-type"), 0, "") - - isJson = - String.contains?(acceptHeader, "application/json") or - String.match?(template, ~r/.*\.json/) - - if isJson do - # If `Content-Type` is `json` but the `Accept` header is not passed, Phoenix considers this as an `.html` request. - # We want to return a JSON, hence why we check if Phoenix considers this an `.html` request. - # - # If so, we return a JSON with appropriate headers. - # We try to return a meaningful error if it exists (:reason). It it doesn't, we return the status message from template - case String.match?(template, ~r/.*\.json/) do - true -> - %{ - error: - Map.get( - conn.assigns.reason, - :message, - Phoenix.Controller.status_message_from_template(template) - ) - } - - false -> - Phoenix.Controller.json( - conn, - %{ - error: - Map.get( - conn.assigns.reason, - :message, - Phoenix.Controller.status_message_from_template(template) - ) - } - ) - end - else - Phoenix.Controller.status_message_from_template(template) - end - end -``` - -In this function, -we are retrieving the `content-type` request header -and asserting if it is `json` or not. -If it does, -we return a `json` response. -Otherwise, we do not. - -Since users sometimes might not send the `accept` request header -but the `content-type` instead, -Phoenix will assume the template is `*.html`-based. -Hence why we are checking for the template -format and returning the response accordingly. - -# 5.1 Fixing tests - -We ought to test these scenarios now! -Open `test/app_web/views/error_view_test.exs` -and add the following piece of code. - -```elixir - - alias Phoenix.ConnTest - - test "testing error view with `Accept` header with `application/json` and passing a `.json` template" do - assigns = %{reason: %{message: "Route not found."}} - - conn = - build_conn() - |> put_req_header("accept", "application/json") - |> Map.put(:assigns, assigns) - - conn = %{conn: conn} - - assert Jason.decode!(render_to_string(AppWeb.ErrorView, "404.json", conn)) == - %{"error" => "Route not found."} - end - - test "testing error view with `Content-type` header with `application/json` and passing a `.json` template" do - assigns = %{reason: %{message: "Route not found."}} - - conn = - build_conn() - |> put_req_header("content-type", "application/json") - |> Map.put(:assigns, assigns) - - conn = %{conn: conn} - - assert Jason.decode!(render_to_string(AppWeb.ErrorView, "404.json", conn)) == - %{"error" => "Route not found."} - end - - test "testing error view with `Content-type` header with `application/json` and passing a `.html` template" do - assigns = %{reason: %{message: "Route not found."}} - - conn = - build_conn() - |> put_req_header("content-type", "application/json") - |> Map.put(:assigns, assigns) - - conn = %{conn: conn} - - resp_body = Map.get(render(AppWeb.ErrorView, "404.html", conn), :resp_body) - - assert Jason.decode!(resp_body) == %{"error" => "Route not found."} - end - - test "testing error view and passing a `.html` template" do - conn = build_conn() - conn = %{conn: conn} - - assert render_to_string(AppWeb.ErrorView, "404.html", conn) == "Not Found" - end -``` - -If we run `mix test`, -you should see the following output! - -```sh -Finished in 1.7 seconds (1.5s async, 0.1s sync) -96 tests, 0 failures -``` - -And our coverage is back to 100%! 🎉 - -# 6. Basic `API` Testing Using `cUrl` - -At this point we have a working `API` for `items` and `timers`. -We can demonstrate it using `curl` commands in the `Terminal`. - -1. Run the `Phoenix` App with the command: `mix s` -2. In a _separate_ `Terminal` window, run the following commands: - -## 6.1 _Create_ an `item` via `API` Request - -```sh -curl -X POST http://localhost:4000/api/items -H 'Content-Type: application/json' -d '{"text":"My Awesome item text"}' -``` -You should expect to see the following result: - -```sh -{"id":1} -``` - -## 6.2 _Read_ the `item` via `API` - -Now if you request this `item` using the `id`: - -```sh -curl http://localhost:4000/api/items/1 -``` - -You should see: - -```sh -{"id":1,"person_id":0,"status":2,"text":"My Awesome item text"} -``` - -This tells us that `items` are being created. ✅ - -## 6.3 Create a `Timer` for your `item` - -The route pattern is: `/api/items/:item_id/timers`. -Therefore our `cURL` request is: - -```sh -curl -X POST http://localhost:4000/api/items/1/timers -H 'Content-Type: application/json' -d '{"start":"2022-10-28T00:00:00"}' -``` - -You should see a response similar to the following: - -```sh -{"id":1} -``` - -This is the `timer.id` and informs us that the timer is running. -You may also create a `timer` without passing a body. -This will create an *ongoing `timer`* -with a `start` value of the current UTC time. - -## 6.4 _Stop_ the `Timer` - -The path to `stop` a timer is `/api/timers/:id`. -Stopping a timer is a simple `PUT` request -without a body. - -```sh -curl -X PUT http://localhost:4000/api/timers/1 -H 'Content-Type: application/json' -``` - -If the timer with the given `id` was not stopped prior, -you should see a response similar to the following: - -```sh -{ - "id": 1, - "start": "2023-01-11T17:40:44", - "stop": "2023-01-17T15:43:24" -} -``` - -Otherwise, an error will surface. - -```sh -{ - "code": 403, - "message": "Timer with the given 'id' has already stopped." -} -``` - -## 6.5 Updating a `Timer` - -You can update a timer with a specific -`stop` and/or `start` attribute. -This can be done in `/api/items/1/timers/1` -with a `PUT` request. - -```sh -curl -X PUT http://localhost:4000/api/items/1/timers/1 -H 'Content-Type: application/json' -d '{"start": "2023-01-11T17:40:44", "stop": "2023-01-11T17:40:45"}' -``` - -If successful, you will see a response like so. - -```sh -{ - "id": 1, - "start": "2023-01-11T17:40:44", - "stop": "2023-01-11T17:40:45" -} -``` - -You might get a `400 - Bad Request` error -if `stop` is before `start` -or the values being passed in the `json` body -are invalid. - -```sh -{ - "code": 400, - "errors": { - "start": [ - "cannot be later than 'stop'" - ] - }, - "message": "Malformed request" -} -``` - -# 7. _Advanced/Automated_ `API` Testing Using `Hoppscotch` - -`API` testing is an essential part -of the development lifecycle. -Incorporating tests will allow us -to avoid regressions -and make sure our `API` performs -the way it's supposed to. -In other words, -the `person` using the API -*expects* consistent responses to their requests. - -Integrating this into a -[CI pipeline](https://en.wikipedia.org/wiki/Continuous_integration) -automates this process -and helps avoiding unintentional breaking changes. - -We are going to be using -[`Hoppscotch`](https://github.com/hoppscotch/hoppscotch). -This is an open source tool -similar to [`Postman`](https://www.postman.com/) -that allow us to make requests, -organize them and create test suites. - -Red more about `Hoppscotch`: -[hoppscotch.io](https://hoppscotch.io) - -## 7.0 `Hoppscotch` Setup - -There is no `App` to download, -but you can run `Hoppscotch` as -an "installable" [`PWA`](https://web.dev/what-are-pwas/): -![hoppscotch-docs-pwa](https://user-images.githubusercontent.com/194400/213877931-47344cfd-4dd7-491e-b032-9e65dff49ebc.png) - -In `Google Chrome` and `Microsoft Edge` -you will see an icon -in the Address bar to -"Install Hoppscotch app": - -image - -That will create what _looks_ like a "Native" App on your `Mac`: - -image - -Which then opens full-screen an _feels_ `Native`: - -Hoppscotch PWA Homescreen - -And you're all set to start testing the `API`. - -> Installing the `PWA` will _significantly_ increase your dev speed -because you can easily ``+`Tab` between your IDE and `Hoppscotch` -and not have to hunt for a Tab in your Web Browser. - -You can use `Hoppscotch` anonymously -(without logging in), -without any loss of functionality. - -If you decide to Authenticate -and you don't want to see the noise in the Top Nav, -simply enable "Zen Mode": - -![hoppscotch-zen-mode](https://user-images.githubusercontent.com/194400/213877013-0ff9c65d-10dc-4741-aa67-395e9fd6adb7.gif) - -With that out of the way, let's get started _using_ `Hoppscotch`! - - -## 7.1 Using `Hoppscotch` - -When you first open `Hoppscotch`, -either in the browser or as a `PWA`, -you will not have anything defined: - -![hoppscotch-empty](https://user-images.githubusercontent.com/194400/213889044-0e38256d-0c59-41f0-bbbe-ee54a16583e2.png) - - -The _first_ thing to do is open an _existing_ collection: - -hoppscotch-open-collection - -Import from hoppscotch: `/lib/api/MVP.json` - -hoppscotch-open-local - -Collection imported: - -image - -_Next_ you'll need to open environment configuration / variables: - -hoppscotch-open-environment - - -![hoppscotch-open-env](https://user-images.githubusercontent.com/194400/213889224-45dd660e-874d-422c-913d-bfdba1052944.png) - -When you click on `Localhost`, you will see an `Edit Environment` Modal: - -image - -**environment variables** -let us switch -between development or production environments seamlessly. - -Even after you have imported the environment configuration file, -it's not automatically selected: - -hoppscotch-environment-not-found - -You need to **_manually_ select `Localhost`**. -With the "Environments" tab selected, click the "Select environment" selector and chose "Localhost": - -hoppscotch-select-environment-localhost - -Once you've selected the `Localhost` environment, the `<>` placeholder will turn from red to blue: - -image - -After importing the collection, -open the `MVP` and `Items` folder, -you will see a list of possible requests. - - -After importing the collection and environment, it _still_ won't work ... -image - -You will see the message: - -**Could not send request**. -Unable to reach the API endpoint. Check your network
connection or select a different interceptor and try again. - - -These are the available options: - -![image](https://user-images.githubusercontent.com/194400/213896782-b96d97a5-5e42-41ec-b299-e64c77246b79.png) - -If you select "Browser extension" it will open the Chrome web store where you can install the extension. - -Install the extension. -Once installed, -add the the `http://localhost:4000` origin: - -add endpoint - -Then the presence of the extension will be visible in the Hoppscotch window/UI: - -![image](https://user-images.githubusercontent.com/194400/213896932-a8f48f2a-f5ee-47c1-aad6-d9a09cf27b48.png) - -image - - -Now you can start testing the requests! -Start the Phoenix server locally -by running `mix s` - -The requests in the collection will _finally_ work: - -![image](https://user-images.githubusercontent.com/194400/213897127-c70a5961-1db6-4d1f-a944-cf08a5bf2f86.png) - - - -If you open `MVP, Items` -and try to `Get Item` (by clicking `Send`), -you will receive a response from the `localhost` server. - -get1 -get2 -get3 - -Depending if the `item` with `id=1` -(which is defined in the *env variable* `item_id` -in the `localhost` environment), -you will receive a successful response -or an error, detailing the error -that the item was not found with the given `id`. - -You can create **tests** for each request, -asserting the response object and HTTP code. -You can do so by clicking the `Tests` tab. - -test - -These tests are important to validate -the expected response of the API. -For further information -on how you can test the response in each request, -please visit their documentation at -https://docsapi.io/features/tests. - -## 7.2 Integration with `Github Actions` with `Hoppscotch CLI` - -These tests can (and should!) -be used in CI pipelines. -To integrate this in our Github Action, -we will need to make some changes to our -[workflow file](https://docs.github.com/en/actions/using-workflows) -in `.github/worflows/ci.yml`. - -We want the [runner](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions#runners) -to be able to *execute* these tests. - -For this, we are going to be using -[**`Hoppscotch CLI`**](https://docs.hoppscotch.io/cli). - -With `hopp` (Hoppscotch CLI), -we will be able to run the collection of requests -and its tests in a command-line environment. - -To run the tests inside a command-line interface, -we are going to need two files: -- **environment file**, -a `json` file with each env variable as key -and its referring value. -For an example, -check the -[`lib/api/localhost.json` file](./lib/api/localhost.json). -- **collection file**, -the `json` file with all the requests. -It is the one you imported earlier. -You can export it the same way you imported it. -For an example, -check the -[`/lib/api/MVP.json` file](./lib/api/MVP.json). - -These files -will need to be pushed into the git repo. -The CI will need access to these files -to run `hopp` commands. - -In the case of our application, -for the tests to run properly, -we need some bootstrap data -so each request runs successfully. -For this, -we also added a -[`api_test_mock_data.sql`](lib/api/api_test_mock_data.sql) -`SQL` script file that will insert some mock data. - -# 7.2.1 Changing the workflow `.yml` file - -It's time to add this API testing step -into our CI workflow! -For this, open `.github/workflows/ci.yml` -and add the following snippet of code -between the `build` and `deploy` jobs. - - -```yml - # API Definition testing - # https://docs.hoppscotch.io/cli - api_definition: - name: API Definition Tests - runs-on: ubuntu-latest - services: - postgres: - image: postgres:12 - ports: ['5432:5432'] - env: - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - strategy: - matrix: - otp: ['25.1.2'] - elixir: ['1.14.2'] - steps: - - uses: actions/checkout@v2 - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - otp-version: ${{ matrix.otp }} - elixir-version: ${{ matrix.elixir }} - - name: Restore deps and _build cache - uses: actions/cache@v3 - with: - path: | - deps - _build - key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} - restore-keys: | - deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} - - name: Install dependencies - run: mix deps.get - - - name: Install Hoppscotch CLI - run: npm i -g @hoppscotch/cli - - - name: Run mix ecto.create - run: mix ecto.create - - - name: Run ecto.migrate - run: mix ecto.migrate - - - name: Bootstrap Postgres DB with data - run: psql -h localhost -p 5432 -d app_dev -U postgres -f ./lib/api/api_test_mock_data.sql - - env: - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - PGPASSWORD: postgres - - - name: Running server and Hoppscotch Tests - run: mix phx.server & sleep 5 && hopp test -e ./lib/api/envs.json ./lib/api/MVP.json -``` - -Let's breakdown what we just added. -We are running this job in a -[service container](https://docs.github.com/en/actions/using-containerized-services/about-service-containers) -that includes a PostgreSQL database - -similarly to the existent `build` job. - -We then install the `Hoppscotch CLI` -by running `npm i -g @hoppscotch/cli`. - -We then run `mix ecto.create` -and `ecto.migrate` -to create and setup the database. - -After this, -we *boostrap* the database with -`psql -h localhost -p 5432 -d app_dev -U postgres -f ./api/api_test_mock_data.sql`. -This command ([`psql`](https://www.postgresql.org/docs/current/app-psql.html)) -allows us to connect to the PostgreSQL database -and execute the `api_test_mock_data.sql` script, -which inserts data for the tests to run. - - -At last, -we run the API by running `mix phx.server` -and execute `hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json`. -This `hopp` command takes the environment file -and the collections file -and executes its tests. -You might notice we are using `sleep 5`. -This is because we want the `hopp` -command to be executed -after `mix phx.server` finishes initializing. - -And you should be done! -When running `hopp test`, -you will see the result of each request test. - -```sh -↳ API.Item.update/2, at: lib/api/item.ex:65 - 400 : Bad Request (0.049 s) -[info] Sent 400 in 4ms - ✔ Status code is 400 - Ran tests in 0.001 s - -Test Cases: 0 failed 31 passed -Test Suites: 0 failed 28 passed -Test Scripts: 0 failed 22 passed -Tests Duration: 0.041 s -``` - -If one test fails, the whole build fails, as well. - - -# Done! ✅ - -This document is going to be expanded -as development continues. -So if you're reading this, it's because that's all we currently have! - -If you found it interesting, -please let us know by starring the repo on GitHub! ⭐ - -
- -[![HitCount](https://hits.dwyl.com/dwyl/app-mvp-api.svg)](https://hits.dwyl.com/dwyl/app-mvp) \ No newline at end of file diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 77e790ed..e0f8b277 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -1,3 +1,24 @@ +# Script for populating the database. +# You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of the repos directly. + if not Envar.is_set?("AUTH_API_KEY") do Envar.load(".env") end + +if Mix.env() == :dev do + App.Item.create_item(%{text: "random text", person_id: 0, status: 2}) + + {:ok, _timer} = + App.Timer.start(%{ + item_id: 1, + start: "2023-01-19 15:52:00", + stop: "2023-01-19 15:52:03" + }) + + {:ok, _timer2} = + App.Timer.start(%{item_id: 1, start: "2023-01-19 15:55:00", stop: nil}) +end From 44c139a37e2699aba42e9203f9f3e1e786285100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 13:56:16 +0000 Subject: [PATCH 07/34] fix: Deleting locust. --- __pycache__/locustfile.cpython-39.pyc | Bin 507 -> 0 bytes locustfile.py | 6 ------ 2 files changed, 6 deletions(-) delete mode 100644 __pycache__/locustfile.cpython-39.pyc delete mode 100644 locustfile.py diff --git a/__pycache__/locustfile.cpython-39.pyc b/__pycache__/locustfile.cpython-39.pyc deleted file mode 100644 index 16f5508a959a86c5b33a3c2be4ba6ceb638bb3ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 507 zcmYjOu};H447KmlDpal1ksX#S4eSV20tp!CQn6$?MY$^kE^WvqNK|a?hrq~(@DsN( z@e54Yjf!H)j_rGX_s&U2qXCc|-F{^o`hIiKwSbB#xjUwyL8FBN6D&j`5UxNAEw`Yh z!HrC$4(1T`eh3MX@Zoh`mv_!oKB!aolySRjl8Pz0JEUMBK@Ev8Qll1I*kBT9Npyh^ zZfa9xNT9uO&eIpL?FN-fjEDCGxA6)W9X&WB1pH>qt;Dt<}De_^Mk6Ark?{ETp%geY*=nbBVQt)hxA ZZ%G)^ndhffOya|{_U?1u+)nLS`~g8 Date: Mon, 23 Jan 2023 13:58:42 +0000 Subject: [PATCH 08/34] merge: Proper merge from main. --- api.md | 75 +++++++++++++++++++++++++--------- lib/api/api_test_mock_data.sql | 10 ----- 2 files changed, 56 insertions(+), 29 deletions(-) delete mode 100644 lib/api/api_test_mock_data.sql diff --git a/api.md b/api.md index 8a7557e1..49b2bfbb 100644 --- a/api.md +++ b/api.md @@ -39,7 +39,8 @@ can also be done through our `REST API` - [7.0 `Hoppscotch` Setup](#70-hoppscotch-setup) - [7.1 Using `Hoppscotch`](#71-using-hoppscotch) - [7.2 Integration with `Github Actions` with `Hoppscotch CLI`](#72-integration-with-github-actions-with-hoppscotch-cli) -- [7.2.1 Changing the workflow `.yml` file](#721-changing-the-workflow-yml-file) + - [7.2.1 Changing the workflow `.yml` file](#721-changing-the-workflow-yml-file) + - [7.2.2 Changing the `priv/repo/seeds.exs` file](#722-changing-the-privreposeedsexs-file) - [Done! ✅](#done-) @@ -1464,7 +1465,7 @@ we also added a [`api_test_mock_data.sql`](lib/api/api_test_mock_data.sql) `SQL` script file that will insert some mock data. -# 7.2.1 Changing the workflow `.yml` file +### 7.2.1 Changing the workflow `.yml` file It's time to add this API testing step into our CI workflow! @@ -1516,14 +1517,14 @@ between the `build` and `deploy` jobs. - name: Install Hoppscotch CLI run: npm i -g @hoppscotch/cli - - name: Run mix ecto.create - run: mix ecto.create + # Setups database and adds seed data for API definition tests + - name: Run mix setup + run: mix setup + env: + MIX_ENV: dev + AUTH_API_KEY: ${{ secrets.AUTH_API_KEY }} - - name: Run ecto.migrate - run: mix ecto.migrate - - name: Bootstrap Postgres DB with data - run: psql -h localhost -p 5432 -d app_dev -U postgres -f ./lib/api/api_test_mock_data.sql env: POSTGRES_HOST: localhost @@ -1543,19 +1544,22 @@ similarly to the existent `build` job. We then install the `Hoppscotch CLI` by running `npm i -g @hoppscotch/cli`. -We then run `mix ecto.create` -and `ecto.migrate` -to create and setup the database. +We then run `mix ecto.setup`. +This command creates the database, +runs the migrations +and executes `run priv/repo/seeds.exs`. +The list of commands is present +in the [`mix.exs` file](./mix.exs). + + + -After this, -we *boostrap* the database with -`psql -h localhost -p 5432 -d app_dev -U postgres -f ./api/api_test_mock_data.sql`. -This command ([`psql`](https://www.postgresql.org/docs/current/app-psql.html)) -allows us to connect to the PostgreSQL database -and execute the `api_test_mock_data.sql` script, -which inserts data for the tests to run. +We are going to change the `seeds.exs` +file to bootstrap the database +with sample data for the API tests to run. + At last, we run the API by running `mix phx.server` and execute `hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json`. @@ -1584,8 +1588,41 @@ Test Scripts: 0 failed 22 passed Tests Duration: 0.041 s ``` -If one test fails, the whole build fails, as well. +If one test fails, the whole build fails, as well! + +### 7.2.2 Changing the `priv/repo/seeds.exs` file + +As we mentioned prior, +the last thing we need to do is +to change our `priv/repo/seeds.exs` file +so it adds sample data for the tests to run +when calling `mix ecto.setup`. +Use the following piece of code +and change `seeds.exs` to look as such. + + +```elixir +if not Envar.is_set?("AUTH_API_KEY") do + Envar.load(".env") +end + +if Mix.env() == :dev do + App.Item.create_item(%{text: "random text", person_id: 0, status: 2}) + + {:ok, _timer} = + App.Timer.start(%{ + item_id: 1, + start: "2023-01-19 15:52:00", + stop: "2023-01-19 15:52:03" + }) + + {:ok, _timer2} = + App.Timer.start(%{item_id: 1, start: "2023-01-19 15:55:00", stop: nil}) +end +``` +We are only adding sample data +when the server is being run in `dev` mode. # Done! ✅ diff --git a/lib/api/api_test_mock_data.sql b/lib/api/api_test_mock_data.sql deleted file mode 100644 index fd97f63d..00000000 --- a/lib/api/api_test_mock_data.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Adding single mock item -INSERT INTO public.items -("text", person_id, status, inserted_at, updated_at) -VALUES('random text', 0, 2, '2023-01-19 13:48:12.000', '2023-01-19 13:48:12.000'); - --- Adding two timers -INSERT INTO public.timers -("start", stop, item_id, inserted_at, updated_at) -VALUES('2023-01-19 15:52:00', '2023-01-19 15:52:03', 1, '2023-01-19 15:52:03.000', '2023-01-19 15:52:03.000'), -('2023-01-19 15:55:00', null, 1, '2023-01-19 15:52:03.000', '2023-01-19 15:52:03.000'); From 27962682ebc4302134a3335133a979739cdaf13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 15:32:50 +0000 Subject: [PATCH 09/34] feat: Adding router.ex changes to API.md. #256 --- api.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 132 insertions(+), 24 deletions(-) diff --git a/api.md b/api.md index 49b2bfbb..3bb51150 100644 --- a/api.md +++ b/api.md @@ -28,19 +28,21 @@ can also be done through our `REST API` - [3. `JSON` serializing](#3-json-serializing) - [4. Listing `timers` and `items` and validating updates](#4-listing-timers-and-items-and-validating-updates) - [5. Error handling in `ErrorView`](#5-error-handling-in-errorview) -- [5.1 Fixing tests](#51-fixing-tests) + - [5.1 Fixing tests](#51-fixing-tests) - [6. Basic `API` Testing Using `cUrl`](#6-basic-api-testing-using-curl) - [6.1 _Create_ an `item` via `API` Request](#61-create-an-item-via-api-request) - [6.2 _Read_ the `item` via `API`](#62-read-the-item-via-api) - [6.3 Create a `Timer` for your `item`](#63-create-a-timer-for-your-item) - [6.4 _Stop_ the `Timer`](#64-stop-the-timer) - [6.5 Updating a `Timer`](#65-updating-a-timer) -- [7. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#7-advancedautomated-api-testing-using-hoppscotch) - - [7.0 `Hoppscotch` Setup](#70-hoppscotch-setup) - - [7.1 Using `Hoppscotch`](#71-using-hoppscotch) - - [7.2 Integration with `Github Actions` with `Hoppscotch CLI`](#72-integration-with-github-actions-with-hoppscotch-cli) - - [7.2.1 Changing the workflow `.yml` file](#721-changing-the-workflow-yml-file) - - [7.2.2 Changing the `priv/repo/seeds.exs` file](#722-changing-the-privreposeedsexs-file) +- [7. Adding `API.Tag`](#7-adding-apitag) +- [7.1 Updating scope and `router.ex` tests](#71-updating-scope-and-routerex-tests) +- [8. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#8-advancedautomated-api-testing-using-hoppscotch) + - [8.0 `Hoppscotch` Setup](#80-hoppscotch-setup) + - [8.1 Using `Hoppscotch`](#81-using-hoppscotch) + - [8.2 Integration with `Github Actions` with `Hoppscotch CLI`](#82-integration-with-github-actions-with-hoppscotch-cli) + - [8.2.1 Changing the workflow `.yml` file](#821-changing-the-workflow-yml-file) + - [8.2.2 Changing the `priv/repo/seeds.exs` file](#822-changing-the-privreposeedsexs-file) - [Done! ✅](#done-) @@ -893,7 +895,7 @@ add the following private function. end end ``` - + If `stop` or `start` is `nil`, we can't compare the datetimes, so we just skip the validation. This usually happens when creating a timer that is ongoing @@ -1033,7 +1035,7 @@ Phoenix will assume the template is `*.html`-based. Hence why we are checking for the template format and returning the response accordingly. -# 5.1 Fixing tests +## 5.1 Fixing tests We ought to test these scenarios now! Open `test/app_web/views/error_view_test.exs` @@ -1227,7 +1229,120 @@ are invalid. } ``` -# 7. _Advanced/Automated_ `API` Testing Using `Hoppscotch` +# 7. Adding `API.Tag` + +Having added API controllers for `item` and `timer`, +it's high time to do the same for `tags`! + +# 7.1 Updating scope and `router.ex` tests + +Let's start by changing our `lib/app_web/router.ex` file, +the same way we did for `items` and `timers`. + +```elixir + scope "/api", API, as: :api do + pipe_through [:api, :authOptional] + + resources "/items", Item, only: [:create, :update, :show] + @@ -43,5 +43,7 @@ defmodule AppWeb.Router do + only: [:create, :update, :show, :index] + + put "/timers/:id", Timer, :stop + + resources "/tags", Tag, only: [:create, :update, :show] + end +``` + +You might have noticed we made two changes: +- we added the `resources "/tags"` line. +We are going to be adding the associated controller +to handle each operation later. +- added an `as:` property when defining the scope - +`as: :api`. + +The latter change pertains to +[**route helpers**](https://hexdocs.pm/phoenix/routing.html#path-helpers). +Before making this change, +if we run the `mix phx.routes` command, +we get the following result in our terminal. + +```sh + ... + + tag_path GET /tags AppWeb.TagController :index + tag_path GET /tags/:id/edit AppWeb.TagController :edit + tag_path GET /tags/new AppWeb.TagController :new + tag_path POST /tags AppWeb.TagController :create + tag_path PATCH /tags/:id AppWeb.TagController :update + PUT /tags/:id AppWeb.TagController :update + tag_path DELETE /tags/:id AppWeb.TagController :delete + + item_path GET /api/items/:id API.Item :show + item_path POST /api/items API.Item :create + item_path PATCH /api/items/:id API.Item :update + PUT /api/items/:id API.Item :update + timer_path GET /api/items/:item_id/timers API.Timer :index + timer_path GET /api/items/:item_id/timers/:id API.Timer :show + timer_path POST /api/items/:item_id/timers API.Timer :create + timer_path PATCH /api/items/:item_id/timers/:id API.Timer :update + PUT /api/items/:item_id/timers/:id API.Timer :update + timer_path PUT /api/timers/:id API.Timer :stop + + ... +``` + +These are the routes that we are currently handling +in our application. +However, we will face some issues +if we added a `Tag` controller for our API. +It will **clash with TagController** +because they share the same path. + +`Item` paths can be accessed by route helper +`Routes.item_path(conn, :show, item.id)`, +as shown in the terminal result. +By adding `as: :api` attribute to our scope, +these route helpers are prefixed with `"api"`, +making it easier to use these Route helpers +differentiate API and browser calls. + +Here's the result after adding +the aforementioned `as:` attribute to the scope. + +```sh + ... + tag_path GET /tags AppWeb.TagController :index + tag_path GET /tags/:id/edit AppWeb.TagController :edit + tag_path GET /tags/new AppWeb.TagController :new + tag_path POST /tags AppWeb.TagController :create + tag_path PATCH /tags/:id AppWeb.TagController :update + PUT /tags/:id AppWeb.TagController :update + tag_path DELETE /tags/:id AppWeb.TagController :delete + api_item_path GET /api/items/:id API.Item :show + api_item_path POST /api/items API.Item :create + api_item_path PATCH /api/items/:id API.Item :update + PUT /api/items/:id API.Item :update +api_timer_path GET /api/items/:item_id/timers API.Timer :index +api_timer_path GET /api/items/:item_id/timers/:id API.Timer :show +api_timer_path POST /api/items/:item_id/timers API.Timer :create +api_timer_path PATCH /api/items/:item_id/timers/:id API.Timer :update + PUT /api/items/:item_id/timers/:id API.Timer :update +api_timer_path PUT /api/timers/:id API.Timer :stop + ... +``` + +Notice that the Route Helpers +are now updated. + +We have used these Route Helpers +in our tests. + +Update these files so they look like the following. +- `test/api/item_test.exs` +- `test/api/timer_test.exs` + + +# 8. _Advanced/Automated_ `API` Testing Using `Hoppscotch` `API` testing is an essential part of the development lifecycle. @@ -1254,7 +1369,7 @@ organize them and create test suites. Red more about `Hoppscotch`: [hoppscotch.io](https://hoppscotch.io) -## 7.0 `Hoppscotch` Setup +## 8.0 `Hoppscotch` Setup There is no `App` to download, but you can run `Hoppscotch` as @@ -1295,7 +1410,7 @@ simply enable "Zen Mode": With that out of the way, let's get started _using_ `Hoppscotch`! -## 7.1 Using `Hoppscotch` +## 8.1 Using `Hoppscotch` When you first open `Hoppscotch`, either in the browser or as a `PWA`, @@ -1416,7 +1531,7 @@ on how you can test the response in each request, please visit their documentation at https://docsapi.io/features/tests. -## 7.2 Integration with `Github Actions` with `Hoppscotch CLI` +## 8.2 Integration with `Github Actions` with `Hoppscotch CLI` These tests can (and should!) be used in CI pipelines. @@ -1465,7 +1580,7 @@ we also added a [`api_test_mock_data.sql`](lib/api/api_test_mock_data.sql) `SQL` script file that will insert some mock data. -### 7.2.1 Changing the workflow `.yml` file +### 8.2.1 Changing the workflow `.yml` file It's time to add this API testing step into our CI workflow! @@ -1519,20 +1634,13 @@ between the `build` and `deploy` jobs. # Setups database and adds seed data for API definition tests - name: Run mix setup - run: mix setup + run: mix ecto.setup env: MIX_ENV: dev AUTH_API_KEY: ${{ secrets.AUTH_API_KEY }} - - - env: - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - PGPASSWORD: postgres - - name: Running server and Hoppscotch Tests - run: mix phx.server & sleep 5 && hopp test -e ./lib/api/envs.json ./lib/api/MVP.json + run: mix phx.server & sleep 5 && hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json ``` Let's breakdown what we just added. @@ -1590,7 +1698,7 @@ Tests Duration: 0.041 s If one test fails, the whole build fails, as well! -### 7.2.2 Changing the `priv/repo/seeds.exs` file +### 8.2.2 Changing the `priv/repo/seeds.exs` file As we mentioned prior, the last thing we need to do is From ed197388a5828bf6c120d25d0f3ea8f95ed5dd92 Mon Sep 17 00:00:00 2001 From: LuchoTurtle Date: Mon, 23 Jan 2023 15:34:48 +0000 Subject: [PATCH 10/34] fix: Updating links in API.md #256 --- api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api.md b/api.md index 3bb51150..18e5d895 100644 --- a/api.md +++ b/api.md @@ -1338,8 +1338,8 @@ We have used these Route Helpers in our tests. Update these files so they look like the following. -- `test/api/item_test.exs` -- `test/api/timer_test.exs` +- [`test/api/item_test.exs`](https://github.com/dwyl/mvp/blob/api_tags-%23256/test/api/item_test.exs) +- [`test/api/timer_test.exs`](https://github.com/dwyl/mvp/blob/27962682ebc4302134a3335133a979739cdaf13e/test/api/timer_test.exs) # 8. _Advanced/Automated_ `API` Testing Using `Hoppscotch` @@ -1743,4 +1743,4 @@ please let us know by starring the repo on GitHub! ⭐
-[![HitCount](https://hits.dwyl.com/dwyl/app-mvp-api.svg)](https://hits.dwyl.com/dwyl/app-mvp) \ No newline at end of file +[![HitCount](https://hits.dwyl.com/dwyl/app-mvp-api.svg)](https://hits.dwyl.com/dwyl/app-mvp) From e73b5b24c5a52ca2a739b8bf16e1141cd3e747c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 15:40:05 +0000 Subject: [PATCH 11/34] fix: Fixing typo in API.md. --- api.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/api.md b/api.md index 18e5d895..b24329ef 100644 --- a/api.md +++ b/api.md @@ -1244,7 +1244,8 @@ the same way we did for `items` and `timers`. pipe_through [:api, :authOptional] resources "/items", Item, only: [:create, :update, :show] - @@ -43,5 +43,7 @@ defmodule AppWeb.Router do + + resources "/items/:item_id/timers", Timer, only: [:create, :update, :show, :index] put "/timers/:id", Timer, :stop @@ -1253,8 +1254,8 @@ the same way we did for `items` and `timers`. end ``` -You might have noticed we made two changes: -- we added the `resources "/tags"` line. +You might have noticed we've made two changes: +- we've added the `resources "/tags"` line. We are going to be adding the associated controller to handle each operation later. - added an `as:` property when defining the scope - @@ -1296,15 +1297,15 @@ in our application. However, we will face some issues if we added a `Tag` controller for our API. It will **clash with TagController** -because they share the same path. +because they share the same path. 💥 `Item` paths can be accessed by route helper `Routes.item_path(conn, :show, item.id)`, as shown in the terminal result. + By adding `as: :api` attribute to our scope, -these route helpers are prefixed with `"api"`, -making it easier to use these Route helpers -differentiate API and browser calls. +these route helpers will now be prefixed with `"api"`, +making it easier differentiate API and browser calls. Here's the result after adding the aforementioned `as:` attribute to the scope. @@ -1331,13 +1332,16 @@ api_timer_path PUT /api/timers/:id API.Timer :stop ... ``` -Notice that the Route Helpers -are now updated. - -We have used these Route Helpers -in our tests. +Notice that the route helpers +have changed. +`item_path` now becomes `**api_item_path**`. +The same thing happens with `timer_path`. -Update these files so they look like the following. +By making this change, +we have broken loads of tests, +as they are using these route helpers. +We need to update them! +Do it so they look like the following. - [`test/api/item_test.exs`](https://github.com/dwyl/mvp/blob/api_tags-%23256/test/api/item_test.exs) - [`test/api/timer_test.exs`](https://github.com/dwyl/mvp/blob/27962682ebc4302134a3335133a979739cdaf13e/test/api/timer_test.exs) From bd9a0068c3e42c2ba9a0642c01b860b830da693a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 16:29:19 +0000 Subject: [PATCH 12/34] fix: Mix format and updating API.md. #256 --- api.md | 317 +++++++++++++++++++++++++++++++++++++++- test/api/item_test.exs | 8 +- test/api/tag_test.exs | 15 +- test/api/timer_test.exs | 16 +- 4 files changed, 343 insertions(+), 13 deletions(-) diff --git a/api.md b/api.md index b24329ef..b703d527 100644 --- a/api.md +++ b/api.md @@ -37,6 +37,10 @@ can also be done through our `REST API` - [6.5 Updating a `Timer`](#65-updating-a-timer) - [7. Adding `API.Tag`](#7-adding-apitag) - [7.1 Updating scope and `router.ex` tests](#71-updating-scope-and-routerex-tests) + - [7.2 Implementing `API.Tag` CRUD operations](#72-implementing-apitag-crud-operations) + - [7.2.1 Adding tests](#721-adding-tests) + - [7.2.2 Adding `JSON` encoding and operations to `Tag` schema](#722-adding-json-encoding-and-operations-to-tag-schema) + - [7.2.3 Implementing `lib/api/tag.ex`](#723-implementing-libapitagex) - [8. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#8-advancedautomated-api-testing-using-hoppscotch) - [8.0 `Hoppscotch` Setup](#80-hoppscotch-setup) - [8.1 Using `Hoppscotch`](#81-using-hoppscotch) @@ -1345,6 +1349,313 @@ Do it so they look like the following. - [`test/api/item_test.exs`](https://github.com/dwyl/mvp/blob/api_tags-%23256/test/api/item_test.exs) - [`test/api/timer_test.exs`](https://github.com/dwyl/mvp/blob/27962682ebc4302134a3335133a979739cdaf13e/test/api/timer_test.exs) +## 7.2 Implementing `API.Tag` CRUD operations + +Having changed the `router.ex` file +to call an unimplemented `Tag` controller, +we ought to address that! + +Let's start by adding tests of +what we expect `/tag` CRUD operations to return. + +Similarly to `item`, +we want to: +- create a `tag`. +- update a `tag` . +- retrieve a `tag`. + +`Tags` receive a `color` parameter, +pertaining to an [hex color code](https://en.wikipedia.org/wiki/Web_colors) - +e.g. `#FFFFFF`. +If none is passed when created, +a random one is generated. + +### 7.2.1 Adding tests + +Let's create the test file +`test/api/tag_test.exs` +and add the following code to it. + +```elixir +defmodule API.TagTest do + use AppWeb.ConnCase + alias App.Tag + + @create_attrs %{person_id: 42, color: "#FFFFFF", text: "some text"} + @update_attrs %{person_id: 43, color: "#DDDDDD", text: "some updated text"} + @invalid_attrs %{person_id: nil, color: nil, text: nil} + @update_invalid_color %{color: "invalid"} + + describe "show" do + test "specific tag", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = get(conn, Routes.api_tag_path(conn, :show, tag.id)) + + assert conn.status == 200 + assert json_response(conn, 200)["id"] == tag.id + assert json_response(conn, 200)["text"] == tag.text + end + + test "not found tag", %{conn: conn} do + conn = get(conn, Routes.api_tag_path(conn, :show, -1)) + + assert conn.status == 404 + end + + test "invalid id (not being an integer)", %{conn: conn} do + conn = get(conn, Routes.api_tag_path(conn, :show, "invalid")) + assert conn.status == 400 + end + end + + describe "create" do + test "a valid tag", %{conn: conn} do + conn = post(conn, Routes.api_tag_path(conn, :create, @create_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") + + assert json_response(conn, 200)["color"] == + Map.get(@create_attrs, "color") + + assert json_response(conn, 200)["person_id"] == + Map.get(@create_attrs, "person_id") + end + + test "an invalid tag", %{conn: conn} do + conn = post(conn, Routes.api_tag_path(conn, :create, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + end + + describe "update" do + test "tag with valid attributes", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @update_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) + end + + test "tag with invalid attributes", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + + test "tag that doesn't exist", %{conn: conn} do + {:ok, _tag} = Tag.create_tag(@create_attrs) + conn = put(conn, Routes.api_tag_path(conn, :update, -1, @update_attrs)) + + assert conn.status == 404 + end + + test "a tag with invalid color", %{conn: conn} do + {:ok, tag} = Tag.create_tag(@create_attrs) + conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @update_invalid_color)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["color"]) > 0 + end + end +end +``` + +In a similar fashion to `item` and `timer`, +we are testing the API with the "Happy Path" +and how it handles receiving invalid attributes. + +### 7.2.2 Adding `JSON` encoding and operations to `Tag` schema + +In our `lib/app/tag.ex` file resides the `Tag` schema. +To correctly encode and decode it in `JSON` format, +we need to add the `@derive` annotation +to the schema declaration. + +```elixir +@derive {Jason.Encoder, only: [:id, :text, :person_id, :color]} + schema "tags" do + field :color, :string + field :person_id, :integer + field :text, :string + many_to_many(:items, Item, join_through: ItemTag) + timestamps() + end +``` + +Additionally, +we want to have a +[non-bang function](https://hexdocs.pm/elixir/main/naming-conventions.html#trailing-bang-foo) +to retrieve a tag item, +so we are able to pattern-match +and inform the person using the API +if the given `id` is invalid +or no `tag` is found. + +Lastly, whenever a `tag` is created, +we need to **check if the `color` is correctly formatted**. +For this, we add a `validate_format` function +to the `tag` *changeset*. + +```elixir +def changeset(tag, attrs \\ %{}) do + tag + |> cast(attrs, [:person_id, :text, :color]) + |> validate_required([:person_id, :text, :color]) + |> validate_format(:color, ~r/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/) + |> unique_constraint([:text, :person_id], name: :tags_text_person_id_index) + end +``` + +We are using +[`regex`](https://en.wikipedia.org/wiki/Regular_expression) +string to validate if the input color +follows the `#XXXXXX` hex color format. + +### 7.2.3 Implementing `lib/api/tag.ex` + +Now that we have the tests +and the necessary changes implemented in `lib/app/tag.ex`, +we are ready to create our controller! + +Create `lib/api/tag.ex` +and past the following code. + +```elixir +defmodule API.Tag do + use AppWeb, :controller + alias App.Tag + import Ecto.Changeset + + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Tag.get_tag(id) do + nil -> + errors = %{ + code: 404, + message: "No tag found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + item -> + json(conn, item) + end + + # ID is not an integer + :error -> + errors = %{ + code: 400, + message: "The \'id\' is not an integer." + } + + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + # Attributes to create tag + # Person_id will be changed when auth is added + attrs = %{ + text: Map.get(params, "text"), + person_id: 0, + color: Map.get(params, "color", App.Color.random()) + } + + case Tag.create_tag(attrs) do + # Successfully creates tag + {:ok, tag} -> + id_tag = Map.take(tag, [:id]) + json(conn, id_tag) + + # Error creating tag + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + + # Get tag with the ID + case Tag.get_tag(id) do + nil -> + errors = %{ + code: 404, + message: "No tag found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + # If tag is found, try to update it + tag -> + case Tag.update_tag(tag, params) do + # Successfully updates tag + {:ok, tag} -> + json(conn, tag) + + # Error creating tag + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + end + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request" + } + + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) + Map.put(errors, :errors, changeset_errors) + end +end +``` + +If you have implemented the `API.Item` and `API.Timer` controllers, +you may notice `API.Tag` follows a similar structure: +- we have a `:create` function for creating a `tag`. +- the `:update` function updates a given `tag`. +- the `:show` function retrieves a `tag` with a given `id`. + +Each function handles errors +through the changeset validation +we implemented earlier. +This is evident in the +`:create` and `:update` functions, +that return an error if, for example, +a `color` has an invalid format. + +And we are all done! +We can check if the tests pass +by running `mix test`. +Your terminal should yield +the following information. + +```sh +Finished in 1.7 seconds (1.6s async, 0.1s sync) +110 tests, 0 failures +``` + +Congratulations! 🎉 +We've just implemented a CRUD `Tag` controller! # 8. _Advanced/Automated_ `API` Testing Using `Hoppscotch` @@ -1663,15 +1974,11 @@ and executes `run priv/repo/seeds.exs`. The list of commands is present in the [`mix.exs` file](./mix.exs). - - - - - We are going to change the `seeds.exs` file to bootstrap the database with sample data for the API tests to run. + At last, we run the API by running `mix phx.server` and execute `hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json`. diff --git a/test/api/item_test.exs b/test/api/item_test.exs index 400c56f4..b2d06cb1 100644 --- a/test/api/item_test.exs +++ b/test/api/item_test.exs @@ -50,14 +50,18 @@ defmodule API.ItemTest do describe "update" do test "item with valid attributes", %{conn: conn} do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = put(conn, Routes.api_item_path(conn, :update, item.id, @update_attrs)) + + conn = + put(conn, Routes.api_item_path(conn, :update, item.id, @update_attrs)) assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) end test "item with invalid attributes", %{conn: conn} do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = put(conn, Routes.api_item_path(conn, :update, item.id, @invalid_attrs)) + + conn = + put(conn, Routes.api_item_path(conn, :update, item.id, @invalid_attrs)) assert length(json_response(conn, 400)["errors"]["text"]) > 0 end diff --git a/test/api/tag_test.exs b/test/api/tag_test.exs index d760cc0b..3b54f234 100644 --- a/test/api/tag_test.exs +++ b/test/api/tag_test.exs @@ -54,7 +54,9 @@ defmodule API.TagTest do describe "update" do test "tag with valid attributes", %{conn: conn} do {:ok, tag} = Tag.create_tag(@create_attrs) - conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @update_attrs)) + + conn = + put(conn, Routes.api_tag_path(conn, :update, tag.id, @update_attrs)) assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) @@ -62,7 +64,9 @@ defmodule API.TagTest do test "tag with invalid attributes", %{conn: conn} do {:ok, tag} = Tag.create_tag(@create_attrs) - conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @invalid_attrs)) + + conn = + put(conn, Routes.api_tag_path(conn, :update, tag.id, @invalid_attrs)) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 @@ -77,7 +81,12 @@ defmodule API.TagTest do test "a tag with invalid color", %{conn: conn} do {:ok, tag} = Tag.create_tag(@create_attrs) - conn = put(conn, Routes.api_tag_path(conn, :update, tag.id, @update_invalid_color)) + + conn = + put( + conn, + Routes.api_tag_path(conn, :update, tag.id, @update_invalid_color) + ) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["color"]) > 0 diff --git a/test/api/timer_test.exs b/test/api/timer_test.exs index 99b0a1b8..99f9d766 100644 --- a/test/api/timer_test.exs +++ b/test/api/timer_test.exs @@ -74,7 +74,10 @@ defmodule API.TimerTest do Item.create_item(@create_item_attrs) conn = - post(conn, Routes.api_timer_path(conn, :create, item.id, @invalid_attrs)) + post( + conn, + Routes.api_timer_path(conn, :create, item.id, @invalid_attrs) + ) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 @@ -151,7 +154,13 @@ defmodule API.TimerTest do conn = put( conn, - Routes.api_timer_path(conn, :update, item.id, timer.id, @invalid_attrs) + Routes.api_timer_path( + conn, + :update, + item.id, + timer.id, + @invalid_attrs + ) ) assert conn.status == 400 @@ -159,7 +168,8 @@ defmodule API.TimerTest do end test "timer that doesn't exist", %{conn: conn} do - conn = put(conn, Routes.api_timer_path(conn, :update, -1, -1, @invalid_attrs)) + conn = + put(conn, Routes.api_timer_path(conn, :update, -1, -1, @invalid_attrs)) assert conn.status == 404 end From b00984cff12e1e6916cab7ff78d4a0d15d8e742b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 16:30:34 +0000 Subject: [PATCH 13/34] fix: Fixing headers in API.md. --- api.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/api.md b/api.md index b703d527..acd195c9 100644 --- a/api.md +++ b/api.md @@ -36,11 +36,11 @@ can also be done through our `REST API` - [6.4 _Stop_ the `Timer`](#64-stop-the-timer) - [6.5 Updating a `Timer`](#65-updating-a-timer) - [7. Adding `API.Tag`](#7-adding-apitag) -- [7.1 Updating scope and `router.ex` tests](#71-updating-scope-and-routerex-tests) - - [7.2 Implementing `API.Tag` CRUD operations](#72-implementing-apitag-crud-operations) - - [7.2.1 Adding tests](#721-adding-tests) - - [7.2.2 Adding `JSON` encoding and operations to `Tag` schema](#722-adding-json-encoding-and-operations-to-tag-schema) - - [7.2.3 Implementing `lib/api/tag.ex`](#723-implementing-libapitagex) + - [7.1 Updating scope and `router.ex` tests](#71-updating-scope-and-routerex-tests) + - [7.2 Implementing `API.Tag` CRUD operations](#72-implementing-apitag-crud-operations) + - [7.2.1 Adding tests](#721-adding-tests) + - [7.2.2 Adding `JSON` encoding and operations to `Tag` schema](#722-adding-json-encoding-and-operations-to-tag-schema) + - [7.2.3 Implementing `lib/api/tag.ex`](#723-implementing-libapitagex) - [8. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#8-advancedautomated-api-testing-using-hoppscotch) - [8.0 `Hoppscotch` Setup](#80-hoppscotch-setup) - [8.1 Using `Hoppscotch`](#81-using-hoppscotch) @@ -1238,7 +1238,7 @@ are invalid. Having added API controllers for `item` and `timer`, it's high time to do the same for `tags`! -# 7.1 Updating scope and `router.ex` tests +## 7.1 Updating scope and `router.ex` tests Let's start by changing our `lib/app_web/router.ex` file, the same way we did for `items` and `timers`. @@ -1349,7 +1349,7 @@ Do it so they look like the following. - [`test/api/item_test.exs`](https://github.com/dwyl/mvp/blob/api_tags-%23256/test/api/item_test.exs) - [`test/api/timer_test.exs`](https://github.com/dwyl/mvp/blob/27962682ebc4302134a3335133a979739cdaf13e/test/api/timer_test.exs) -## 7.2 Implementing `API.Tag` CRUD operations +### 7.2 Implementing `API.Tag` CRUD operations Having changed the `router.ex` file to call an unimplemented `Tag` controller, @@ -1370,7 +1370,7 @@ e.g. `#FFFFFF`. If none is passed when created, a random one is generated. -### 7.2.1 Adding tests +#### 7.2.1 Adding tests Let's create the test file `test/api/tag_test.exs` @@ -1469,7 +1469,7 @@ In a similar fashion to `item` and `timer`, we are testing the API with the "Happy Path" and how it handles receiving invalid attributes. -### 7.2.2 Adding `JSON` encoding and operations to `Tag` schema +#### 7.2.2 Adding `JSON` encoding and operations to `Tag` schema In our `lib/app/tag.ex` file resides the `Tag` schema. To correctly encode and decode it in `JSON` format, @@ -1516,7 +1516,7 @@ We are using string to validate if the input color follows the `#XXXXXX` hex color format. -### 7.2.3 Implementing `lib/api/tag.ex` +#### 7.2.3 Implementing `lib/api/tag.ex` Now that we have the tests and the necessary changes implemented in `lib/app/tag.ex`, From a33b7901540dfc93ccf20686e2e8fee128fa3634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 16:41:38 +0000 Subject: [PATCH 14/34] fix: Fixing typos in API.md. --- api.md | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/api.md b/api.md index acd195c9..7f394498 100644 --- a/api.md +++ b/api.md @@ -36,7 +36,7 @@ can also be done through our `REST API` - [6.4 _Stop_ the `Timer`](#64-stop-the-timer) - [6.5 Updating a `Timer`](#65-updating-a-timer) - [7. Adding `API.Tag`](#7-adding-apitag) - - [7.1 Updating scope and `router.ex` tests](#71-updating-scope-and-routerex-tests) + - [7.1 Updating scope in `router.ex` and tests](#71-updating-scope-in-routerex-and-tests) - [7.2 Implementing `API.Tag` CRUD operations](#72-implementing-apitag-crud-operations) - [7.2.1 Adding tests](#721-adding-tests) - [7.2.2 Adding `JSON` encoding and operations to `Tag` schema](#722-adding-json-encoding-and-operations-to-tag-schema) @@ -1238,7 +1238,7 @@ are invalid. Having added API controllers for `item` and `timer`, it's high time to do the same for `tags`! -## 7.1 Updating scope and `router.ex` tests +## 7.1 Updating scope in `router.ex` and tests Let's start by changing our `lib/app_web/router.ex` file, the same way we did for `items` and `timers`. @@ -1259,6 +1259,7 @@ the same way we did for `items` and `timers`. ``` You might have noticed we've made two changes: + - we've added the `resources "/tags"` line. We are going to be adding the associated controller to handle each operation later. @@ -1299,7 +1300,7 @@ we get the following result in our terminal. These are the routes that we are currently handling in our application. However, we will face some issues -if we added a `Tag` controller for our API. +if we add a `Tag` controller for our API. It will **clash with TagController** because they share the same path. 💥 @@ -1338,14 +1339,19 @@ api_timer_path PUT /api/timers/:id API.Timer :stop Notice that the route helpers have changed. -`item_path` now becomes `**api_item_path**`. +`item_path` now becomes **`api_item_path`**. The same thing happens with `timer_path`. By making this change, -we have broken loads of tests, +we have broken a handful of tests, as they are using these route helpers. We need to update them! -Do it so they look like the following. + + +Update all the route helper calls +with "`api`" prefixed to fix this. +The files should now look like this: + - [`test/api/item_test.exs`](https://github.com/dwyl/mvp/blob/api_tags-%23256/test/api/item_test.exs) - [`test/api/timer_test.exs`](https://github.com/dwyl/mvp/blob/27962682ebc4302134a3335133a979739cdaf13e/test/api/timer_test.exs) @@ -1496,10 +1502,18 @@ and inform the person using the API if the given `id` is invalid or no `tag` is found. +For this, add the following line +to `lib/app/tag.ex`'s list of functions. + +```elixir +def get_tag(id), do: Repo.get(Tag, id) +``` + Lastly, whenever a `tag` is created, we need to **check if the `color` is correctly formatted**. -For this, we add a `validate_format` function -to the `tag` *changeset*. +For this, we add a `validate_format` +[validation function](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-validations-and-constraints) +to the `tag` changeset function. ```elixir def changeset(tag, attrs \\ %{}) do @@ -1513,7 +1527,7 @@ def changeset(tag, attrs \\ %{}) do We are using [`regex`](https://en.wikipedia.org/wiki/Regular_expression) -string to validate if the input color +to validate if the input color string follows the `#XXXXXX` hex color format. #### 7.2.3 Implementing `lib/api/tag.ex` @@ -1640,7 +1654,7 @@ through the changeset validation we implemented earlier. This is evident in the `:create` and `:update` functions, -that return an error if, for example, +as they return an error if, for example, a `color` has an invalid format. And we are all done! From 283d3ffadcaa87dcee7527cf80eb78234818865e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 18:27:14 +0000 Subject: [PATCH 15/34] feat: Adding new Tag API tests. #256 --- lib/api/MVP.json | 369 +---------------------------------------- lib/api/localhost.json | 52 +++++- 2 files changed, 44 insertions(+), 377 deletions(-) diff --git a/lib/api/MVP.json b/lib/api/MVP.json index 577b22bb..dc6450db 100644 --- a/lib/api/MVP.json +++ b/lib/api/MVP.json @@ -1,368 +1 @@ -{ - "folders": [ - { - "requests": [ - { - "v": "1", - "endpoint": "<>/api/items/<>", - "name": "Get item", - "params": [], - "headers": [ - { "active": true, "value": "application/json", "key": "accept" } - ], - "method": "GET", - "auth": { - "authType": "none", - "addTo": "Headers", - "authActive": true, - "value": "", - "key": "" - }, - "preRequestScript": "", - "testScript": "// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});", - "body": { "contentType": null, "body": null } - }, - { - "v": "1", - "endpoint": "<>/api/items/<>", - "name": "Get item (404 - Item not found)", - "params": [], - "headers": [ - { "key": "accept", "value": "application/json", "active": true } - ], - "method": "GET", - "auth": { "authType": "none", "authActive": true }, - "preRequestScript": "", - "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", - "body": { "body": null, "contentType": null } - }, - { - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "auth": { "authActive": true, "authType": "none" }, - "preRequestScript": "", - "params": [], - "body": { "body": null, "contentType": null }, - "name": "Get item (400 - Invalid ID)", - "v": "1", - "endpoint": "<>/api/items/<>", - "method": "GET", - "headers": [ - { "key": "accept", "active": true, "value": "application/json" } - ] - }, - { - "v": "1", - "endpoint": "<>/api/items", - "name": "Create item", - "params": [], - "headers": [ - { "key": "accept", "active": true, "value": "application/json" } - ], - "method": "POST", - "auth": { "authType": "none", "authActive": true }, - "preRequestScript": "", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", - "body": { - "body": "{\n \"text\": \"some text\"\n}", - "contentType": "application/json" - } - }, - { - "name": "Create item (400 - Invalid attributes)", - "endpoint": "<>/api/items", - "method": "POST", - "auth": { "authActive": true, "authType": "none" }, - "params": [], - "preRequestScript": "", - "v": "1", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "body": { - "contentType": "application/json", - "body": "{\n \"invalid\": \"something\"\n}" - }, - "headers": [ - { "value": "application/json", "active": true, "key": "accept" } - ] - }, - { - "v": "1", - "endpoint": "<>/api/items/<>", - "name": "Update item", - "params": [], - "headers": [ - { "active": true, "value": "application/json", "key": "accept" } - ], - "method": "PUT", - "auth": { "authActive": true, "authType": "none" }, - "preRequestScript": "", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});", - "body": { - "body": "{\n \"text\": \"new updated text\"\n}", - "contentType": "application/json" - } - }, - { - "preRequestScript": "", - "params": [], - "body": { - "contentType": "application/json", - "body": "{\n \"text\": \"new updated text\"\n}" - }, - "v": "1", - "name": "Update item (404 - Item not found)", - "headers": [ - { "active": true, "value": "application/json", "key": "accept" } - ], - "method": "PUT", - "auth": { "authType": "none", "authActive": true }, - "endpoint": "<>/api/items/<>", - "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});" - }, - { - "v": "1", - "method": "PUT", - "body": { - "contentType": "application/json", - "body": "{\n \"invalid\": \"invalid\"\n}" - }, - "endpoint": "<>/api/items/<>", - "headers": [ - { "active": true, "key": "accept", "value": "application/json" } - ], - "params": [], - "preRequestScript": "", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "name": "Update item (400 - Invalid attributes)", - "auth": { "authActive": true, "authType": "none" } - } - ], - "name": "Items", - "v": 1, - "folders": [] - }, - { - "v": 1, - "folders": [], - "name": "Timers", - "requests": [ - { - "body": { "body": null, "contentType": null }, - "method": "GET", - "name": "Get timers", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", - "preRequestScript": "", - "v": "1", - "auth": { "authType": "none", "authActive": true }, - "params": [], - "headers": [ - { "key": "accept", "active": true, "value": "application/json" } - ], - "endpoint": "<>/api/items/<>/timers" - }, - { - "auth": { "authActive": true, "authType": "none" }, - "headers": [ - { "key": "accept", "active": true, "value": "application/json" } - ], - "endpoint": "<>/api/items/<>/timers/<>", - "preRequestScript": "", - "name": "Get timer", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", - "body": { "contentType": null, "body": null }, - "params": [], - "v": "1", - "method": "GET" - }, - { - "headers": [ - { "active": true, "value": "application/json", "key": "accept" } - ], - "method": "GET", - "name": "Get timer (404 - Timer not found)", - "auth": { "authType": "none", "authActive": true }, - "params": [], - "v": "1", - "body": { "contentType": null, "body": null }, - "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", - "preRequestScript": "", - "endpoint": "<>/api/items/<>/timers/<>" - }, - { - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "endpoint": "<>/api/items/<>/timers/<>", - "v": "1", - "method": "GET", - "body": { "contentType": null, "body": null }, - "auth": { "authType": "none", "authActive": true }, - "params": [], - "preRequestScript": "", - "headers": [ - { "value": "application/json", "key": "accept", "active": true } - ], - "name": "Get timer (400 - Invalid ID)" - }, - { - "endpoint": "<>/api/items/<>/timers", - "headers": [ - { "key": "accept", "value": "application/json", "active": true } - ], - "body": { - "body": "{\n \"start\": \"2023-01-11T17:40:44\"\n}", - "contentType": "application/json" - }, - "preRequestScript": "", - "v": "1", - "name": "Create timer (custom start)", - "auth": { "authActive": true, "authType": "none" }, - "method": "POST", - "params": [], - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});" - }, - { - "auth": { "authActive": true, "authType": "none" }, - "endpoint": "<>/api/items/<>/timers", - "v": "1", - "method": "POST", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", - "params": [], - "name": "Create timer (no start)", - "preRequestScript": "", - "headers": [ - { "key": "accept", "active": true, "value": "application/json" } - ], - "body": { "contentType": null, "body": null } - }, - { - "headers": [ - { "key": "accept", "value": "application/json", "active": true } - ], - "endpoint": "<>/api/items/<>/timers", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "params": [], - "v": "1", - "body": { - "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}", - "contentType": "application/json" - }, - "name": "Create timer (400 - Stop is after start) ", - "method": "POST", - "auth": { "authType": "none", "authActive": true }, - "preRequestScript": "" - }, - { - "name": "Create timer (400 - Invalid date format) ", - "body": { - "contentType": "application/json", - "body": "{\n \"start\": \"2023-invalid-01\"\n}" - }, - "headers": [ - { "active": true, "value": "application/json", "key": "accept" } - ], - "params": [], - "method": "POST", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "v": "1", - "endpoint": "<>/api/items/<>/timers", - "preRequestScript": "", - "auth": { "authType": "none", "authActive": true } - }, - { - "v": "1", - "endpoint": "<>/api/items/<>/timers/<>", - "name": "Update timer", - "params": [], - "headers": [ - { "active": true, "key": "accept", "value": "application/json" } - ], - "method": "PUT", - "auth": { "authType": "none", "authActive": true }, - "preRequestScript": "", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});", - "body": { - "contentType": "application/json", - "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}" - } - }, - { - "preRequestScript": "", - "endpoint": "<>/api/items/<>/timers/<>", - "params": [], - "v": "1", - "headers": [ - { "active": true, "value": "application/json", "key": "accept" } - ], - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "body": { - "body": "{\n \"start\": \"2023-invalid-01\"\n}", - "contentType": "application/json" - }, - "name": "Update timer (400 - Invalid date format)", - "method": "PUT", - "auth": { "authType": "none", "authActive": true } - }, - { - "preRequestScript": "", - "endpoint": "<>/api/items/<>/timers/<>", - "body": { - "contentType": "application/json", - "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}" - }, - "auth": { "authActive": true, "authType": "none" }, - "headers": [ - { "key": "accept", "active": true, "value": "application/json" } - ], - "v": "1", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "name": "Update timer (400 - Stop is after start)", - "params": [], - "method": "PUT" - }, - { - "name": "Stop timer", - "method": "PUT", - "auth": { "authType": "none", "authActive": true }, - "params": [], - "v": "1", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", - "headers": [ - { "key": "accept", "value": "application/json", "active": true } - ], - "body": { "body": null, "contentType": null }, - "preRequestScript": "", - "endpoint": "<>/api/timers/<>" - }, - { - "preRequestScript": "", - "headers": [ - { "active": true, "key": "accept", "value": "application/json" } - ], - "endpoint": "<>/api/timers/<>", - "body": { "body": null, "contentType": null }, - "method": "PUT", - "name": "Stop timer (403 - Timer has already been stopped)", - "v": "1", - "params": [], - "testScript": "\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});", - "auth": { "authType": "none", "authActive": true } - }, - { - "preRequestScript": "", - "body": { "contentType": null, "body": null }, - "v": "1", - "method": "PUT", - "name": "Stop timer (404 - Timer not found)", - "endpoint": "<>/api/timers/<>", - "auth": { "authActive": true, "authType": "none" }, - "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", - "headers": [ - { "value": "application/json", "key": "accept", "active": true } - ], - "params": [] - } - ] - } - ], - "v": 1, - "name": "MVP", - "requests": [] -} +{"requests":[],"name":"MVP","v":1,"folders":[{"name":"Items","folders":[],"v":1,"requests":[{"endpoint":"<>/api/items/<>","method":"GET","auth":{"value":"","authType":"none","authActive":true,"key":"","addTo":"Headers"},"headers":[{"value":"application/json","active":true,"key":"accept"}],"body":{"contentType":null,"body":null},"v":"1","testScript":"// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});","name":"Get item","preRequestScript":"","params":[]},{"body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"headers":[{"key":"accept","active":true,"value":"application/json"}],"endpoint":"<>/api/items/<>","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","params":[],"name":"Get item (404 - Item not found)","method":"GET","v":"1","preRequestScript":""},{"body":{"body":null,"contentType":null},"headers":[{"active":true,"value":"application/json","key":"accept"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","v":"1","params":[],"method":"GET","auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>","preRequestScript":"","name":"Get item (400 - Invalid ID)"},{"params":[],"body":{"contentType":"application/json","body":"{\n \"text\": \"some text\"\n}"},"auth":{"authActive":true,"authType":"none"},"v":"1","name":"Create item","preRequestScript":"","endpoint":"<>/api/items","method":"POST","headers":[{"active":true,"key":"accept","value":"application/json"}],"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});"},{"preRequestScript":"","body":{"body":"{\n \"invalid\": \"something\"\n}","contentType":"application/json"},"method":"POST","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"v":"1","endpoint":"<>/api/items","auth":{"authType":"none","authActive":true},"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","name":"Create item (400 - Invalid attributes)"},{"endpoint":"<>/api/items/<>","name":"Update item","v":"1","body":{"body":"{\n \"text\": \"new updated text\"\n}","contentType":"application/json"},"headers":[{"key":"accept","value":"application/json","active":true}],"auth":{"authType":"none","authActive":true},"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});","params":[],"preRequestScript":"","method":"PUT"},{"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","name":"Update item (404 - Item not found)","endpoint":"<>/api/items/<>","preRequestScript":"","params":[],"auth":{"authActive":true,"authType":"none"},"v":"1","body":{"contentType":"application/json","body":"{\n \"text\": \"new updated text\"\n}"},"testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});"},{"headers":[{"active":true,"value":"application/json","key":"accept"}],"preRequestScript":"","endpoint":"<>/api/items/<>","method":"PUT","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","params":[],"body":{"contentType":"application/json","body":"{\n \"invalid\": \"invalid\"\n}"},"v":"1","name":"Update item (400 - Invalid attributes)","auth":{"authActive":true,"authType":"none"}}]},{"name":"Timers","v":1,"folders":[],"requests":[{"headers":[{"key":"accept","active":true,"value":"application/json"}],"body":{"body":null,"contentType":null},"endpoint":"<>/api/items/<>/timers","auth":{"authType":"none","authActive":true},"method":"GET","name":"Get timers","params":[],"v":"1","preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});"},{"preRequestScript":"","method":"GET","body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>/timers/<>","params":[],"name":"Get timer","headers":[{"key":"accept","active":true,"value":"application/json"}],"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});","v":"1"},{"auth":{"authType":"none","authActive":true},"params":[],"name":"Get timer (404 - Timer not found)","body":{"contentType":null,"body":null},"headers":[{"key":"accept","active":true,"value":"application/json"}],"endpoint":"<>/api/items/<>/timers/<>","v":"1","method":"GET","preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});"},{"auth":{"authType":"none","authActive":true},"body":{"body":null,"contentType":null},"params":[],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","headers":[{"key":"accept","value":"application/json","active":true}],"name":"Get timer (400 - Invalid ID)","endpoint":"<>/api/items/<>/timers/<>","method":"GET","preRequestScript":"","v":"1"},{"v":"1","body":{"body":"{\n \"start\": \"2023-01-11T17:40:44\"\n}","contentType":"application/json"},"headers":[{"active":true,"key":"accept","value":"application/json"}],"params":[],"method":"POST","name":"Create timer (custom start)","preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","endpoint":"<>/api/items/<>/timers","auth":{"authType":"none","authActive":true}},{"v":"1","headers":[{"value":"application/json","key":"accept","active":true}],"preRequestScript":"","body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"method":"POST","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","name":"Create timer (no start)","params":[],"endpoint":"<>/api/items/<>/timers"},{"method":"POST","preRequestScript":"","auth":{"authType":"none","authActive":true},"params":[],"v":"1","name":"Create timer (400 - Stop is after start) ","headers":[{"value":"application/json","active":true,"key":"accept"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","endpoint":"<>/api/items/<>/timers","body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}"}},{"method":"POST","v":"1","params":[],"auth":{"authType":"none","authActive":true},"headers":[{"value":"application/json","key":"accept","active":true}],"endpoint":"<>/api/items/<>/timers","preRequestScript":"","body":{"body":"{\n \"start\": \"2023-invalid-01\"\n}","contentType":"application/json"},"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","name":"Create timer (400 - Invalid date format) "},{"preRequestScript":"","auth":{"authActive":true,"authType":"none"},"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});","name":"Update timer","v":"1","endpoint":"<>/api/items/<>/timers/<>","headers":[{"value":"application/json","active":true,"key":"accept"}],"method":"PUT","params":[],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}"}},{"params":[],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-invalid-01\"\n}"},"endpoint":"<>/api/items/<>/timers/<>","v":"1","method":"PUT","name":"Update timer (400 - Invalid date format)","headers":[{"key":"accept","active":true,"value":"application/json"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","preRequestScript":"","auth":{"authActive":true,"authType":"none"}},{"method":"PUT","auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>/timers/<>","params":[],"preRequestScript":"","v":"1","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","headers":[{"key":"accept","value":"application/json","active":true}],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}"},"name":"Update timer (400 - Stop is after start)"},{"body":{"contentType":null,"body":null},"name":"Stop timer","params":[],"v":"1","endpoint":"<>/api/timers/<>","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});","headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"PUT","preRequestScript":"","auth":{"authType":"none","authActive":true}},{"method":"PUT","name":"Stop timer (403 - Timer has already been stopped)","body":{"body":null,"contentType":null},"endpoint":"<>/api/timers/<>","params":[],"testScript":"\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});","headers":[{"key":"accept","active":true,"value":"application/json"}],"v":"1","auth":{"authType":"none","authActive":true},"preRequestScript":""},{"method":"PUT","params":[],"name":"Stop timer (404 - Timer not found)","auth":{"authType":"none","authActive":true},"preRequestScript":"","headers":[{"value":"application/json","active":true,"key":"accept"}],"v":"1","endpoint":"<>/api/timers/<>","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":null,"body":null}}]},{"v":1,"name":"Tags","folders":[],"requests":[{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag","params":[],"headers":[{"value":"application/json","active":true,"key":"accept"}],"method":"GET","auth":{"value":"","authType":"none","authActive":true,"key":"","addTo":"Headers"},"preRequestScript":"","testScript":"// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n \n});","body":{"contentType":null,"body":null}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag (404 - Tag not found)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"GET","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":null,"body":null}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag (400 - Invalid ID)","params":[],"headers":[{"active":true,"value":"application/json","key":"accept"}],"method":"GET","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"body":null,"contentType":null}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag","params":[],"headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"POST","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text\",\n \"person_id\": 0,\n \"color\": \"#FFFFFF\"\n}"}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag (no color provided)","params":[],"headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"POST","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text 2\",\n \"person_id\": 0\n}"}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag (400 - Invalid attributes)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"POST","auth":{"authType":"none","authActive":true},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"body":"{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}","contentType":"application/json"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag","params":[],"headers":[{"key":"accept","value":"application/json","active":true}],"method":"PUT","auth":{"authType":"none","authActive":true},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n});","body":{"body":"{\n \"text\": \"new updated tag text\"\n}","contentType":"application/json"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag (404 - Tag not found)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"new updated text\"\n}"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag (400 - Invalid attributes)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}"}}]}]} \ No newline at end of file diff --git a/lib/api/localhost.json b/lib/api/localhost.json index 6daedd25..91df5403 100644 --- a/lib/api/localhost.json +++ b/lib/api/localhost.json @@ -1,9 +1,43 @@ -{ - "host": "http://localhost:4000", - "item_id": "1", - "notfound_item_id": "-1", - "invalid_id": "invalid_id", - "timer_id": "1", - "notfound_timer_id": "-1", - "timer_id_to_stop": 2 -} \ No newline at end of file +[ + { + "name": "Localhost", + "variables": [ + { + "key": "host", + "value": "http://localhost:4000" + }, + { + "value": "1", + "key": "item_id" + }, + { + "value": "-1", + "key": "notfound_item_id" + }, + { + "value": "invalid_id", + "key": "invalid_id" + }, + { + "value": "1", + "key": "timer_id" + }, + { + "value": "-1", + "key": "notfound_timer_id" + }, + { + "value": "2", + "key": "timer_id_to_stop" + }, + { + "key": "tag_id", + "value": "1" + }, + { + "key": "notfound_tag_id", + "value": "-1" + } + ] + } +] \ No newline at end of file From 8eb53ea4a30001eabcd24b394d315c3a98207e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 18:31:05 +0000 Subject: [PATCH 16/34] feat: Adding seed to fix Hopps tests. #256 --- priv/repo/seeds.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index e0f8b277..5b440fa2 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -10,8 +10,10 @@ if not Envar.is_set?("AUTH_API_KEY") do end if Mix.env() == :dev do + # Create item App.Item.create_item(%{text: "random text", person_id: 0, status: 2}) + # Create timers {:ok, _timer} = App.Timer.start(%{ item_id: 1, @@ -21,4 +23,8 @@ if Mix.env() == :dev do {:ok, _timer2} = App.Timer.start(%{item_id: 1, start: "2023-01-19 15:55:00", stop: nil}) + + # Create tags + {:ok, _tag} = + App.Tag.create_tag(%{text: "tag text", person_id: 0, color: "#FFFFFF"}) end From c4d63a0867225dc91e17e11aa08e358007dd00f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 18:36:11 +0000 Subject: [PATCH 17/34] fix: Fixing env CI file. #256 --- lib/api/envs.json | 8 +++++++ lib/api/localhost.json | 54 +++++++++--------------------------------- 2 files changed, 19 insertions(+), 43 deletions(-) diff --git a/lib/api/envs.json b/lib/api/envs.json index 4259a7c0..91df5403 100644 --- a/lib/api/envs.json +++ b/lib/api/envs.json @@ -29,6 +29,14 @@ { "value": "2", "key": "timer_id_to_stop" + }, + { + "key": "tag_id", + "value": "1" + }, + { + "key": "notfound_tag_id", + "value": "-1" } ] } diff --git a/lib/api/localhost.json b/lib/api/localhost.json index 91df5403..b48f28cd 100644 --- a/lib/api/localhost.json +++ b/lib/api/localhost.json @@ -1,43 +1,11 @@ -[ - { - "name": "Localhost", - "variables": [ - { - "key": "host", - "value": "http://localhost:4000" - }, - { - "value": "1", - "key": "item_id" - }, - { - "value": "-1", - "key": "notfound_item_id" - }, - { - "value": "invalid_id", - "key": "invalid_id" - }, - { - "value": "1", - "key": "timer_id" - }, - { - "value": "-1", - "key": "notfound_timer_id" - }, - { - "value": "2", - "key": "timer_id_to_stop" - }, - { - "key": "tag_id", - "value": "1" - }, - { - "key": "notfound_tag_id", - "value": "-1" - } - ] - } -] \ No newline at end of file +{ + "host": "http://localhost:4000", + "item_id": "1", + "notfound_item_id": "-1", + "invalid_id": "invalid_id", + "timer_id": "1", + "notfound_timer_id": "-1", + "timer_id_to_stop": 2, + "tag_id": 1, + "notfound_tag_id": -1 +} \ No newline at end of file From 682513fb9cbcc424a49bede59793935738f91e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 23 Jan 2023 18:41:38 +0000 Subject: [PATCH 18/34] feat: Fixing tag test. #256 --- lib/api/MVP.json | 2 +- priv/repo/seeds.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/api/MVP.json b/lib/api/MVP.json index dc6450db..e88d740c 100644 --- a/lib/api/MVP.json +++ b/lib/api/MVP.json @@ -1 +1 @@ -{"requests":[],"name":"MVP","v":1,"folders":[{"name":"Items","folders":[],"v":1,"requests":[{"endpoint":"<>/api/items/<>","method":"GET","auth":{"value":"","authType":"none","authActive":true,"key":"","addTo":"Headers"},"headers":[{"value":"application/json","active":true,"key":"accept"}],"body":{"contentType":null,"body":null},"v":"1","testScript":"// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});","name":"Get item","preRequestScript":"","params":[]},{"body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"headers":[{"key":"accept","active":true,"value":"application/json"}],"endpoint":"<>/api/items/<>","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","params":[],"name":"Get item (404 - Item not found)","method":"GET","v":"1","preRequestScript":""},{"body":{"body":null,"contentType":null},"headers":[{"active":true,"value":"application/json","key":"accept"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","v":"1","params":[],"method":"GET","auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>","preRequestScript":"","name":"Get item (400 - Invalid ID)"},{"params":[],"body":{"contentType":"application/json","body":"{\n \"text\": \"some text\"\n}"},"auth":{"authActive":true,"authType":"none"},"v":"1","name":"Create item","preRequestScript":"","endpoint":"<>/api/items","method":"POST","headers":[{"active":true,"key":"accept","value":"application/json"}],"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});"},{"preRequestScript":"","body":{"body":"{\n \"invalid\": \"something\"\n}","contentType":"application/json"},"method":"POST","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"v":"1","endpoint":"<>/api/items","auth":{"authType":"none","authActive":true},"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","name":"Create item (400 - Invalid attributes)"},{"endpoint":"<>/api/items/<>","name":"Update item","v":"1","body":{"body":"{\n \"text\": \"new updated text\"\n}","contentType":"application/json"},"headers":[{"key":"accept","value":"application/json","active":true}],"auth":{"authType":"none","authActive":true},"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});","params":[],"preRequestScript":"","method":"PUT"},{"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","name":"Update item (404 - Item not found)","endpoint":"<>/api/items/<>","preRequestScript":"","params":[],"auth":{"authActive":true,"authType":"none"},"v":"1","body":{"contentType":"application/json","body":"{\n \"text\": \"new updated text\"\n}"},"testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});"},{"headers":[{"active":true,"value":"application/json","key":"accept"}],"preRequestScript":"","endpoint":"<>/api/items/<>","method":"PUT","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","params":[],"body":{"contentType":"application/json","body":"{\n \"invalid\": \"invalid\"\n}"},"v":"1","name":"Update item (400 - Invalid attributes)","auth":{"authActive":true,"authType":"none"}}]},{"name":"Timers","v":1,"folders":[],"requests":[{"headers":[{"key":"accept","active":true,"value":"application/json"}],"body":{"body":null,"contentType":null},"endpoint":"<>/api/items/<>/timers","auth":{"authType":"none","authActive":true},"method":"GET","name":"Get timers","params":[],"v":"1","preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});"},{"preRequestScript":"","method":"GET","body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>/timers/<>","params":[],"name":"Get timer","headers":[{"key":"accept","active":true,"value":"application/json"}],"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});","v":"1"},{"auth":{"authType":"none","authActive":true},"params":[],"name":"Get timer (404 - Timer not found)","body":{"contentType":null,"body":null},"headers":[{"key":"accept","active":true,"value":"application/json"}],"endpoint":"<>/api/items/<>/timers/<>","v":"1","method":"GET","preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});"},{"auth":{"authType":"none","authActive":true},"body":{"body":null,"contentType":null},"params":[],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","headers":[{"key":"accept","value":"application/json","active":true}],"name":"Get timer (400 - Invalid ID)","endpoint":"<>/api/items/<>/timers/<>","method":"GET","preRequestScript":"","v":"1"},{"v":"1","body":{"body":"{\n \"start\": \"2023-01-11T17:40:44\"\n}","contentType":"application/json"},"headers":[{"active":true,"key":"accept","value":"application/json"}],"params":[],"method":"POST","name":"Create timer (custom start)","preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","endpoint":"<>/api/items/<>/timers","auth":{"authType":"none","authActive":true}},{"v":"1","headers":[{"value":"application/json","key":"accept","active":true}],"preRequestScript":"","body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"method":"POST","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","name":"Create timer (no start)","params":[],"endpoint":"<>/api/items/<>/timers"},{"method":"POST","preRequestScript":"","auth":{"authType":"none","authActive":true},"params":[],"v":"1","name":"Create timer (400 - Stop is after start) ","headers":[{"value":"application/json","active":true,"key":"accept"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","endpoint":"<>/api/items/<>/timers","body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}"}},{"method":"POST","v":"1","params":[],"auth":{"authType":"none","authActive":true},"headers":[{"value":"application/json","key":"accept","active":true}],"endpoint":"<>/api/items/<>/timers","preRequestScript":"","body":{"body":"{\n \"start\": \"2023-invalid-01\"\n}","contentType":"application/json"},"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","name":"Create timer (400 - Invalid date format) "},{"preRequestScript":"","auth":{"authActive":true,"authType":"none"},"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});","name":"Update timer","v":"1","endpoint":"<>/api/items/<>/timers/<>","headers":[{"value":"application/json","active":true,"key":"accept"}],"method":"PUT","params":[],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}"}},{"params":[],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-invalid-01\"\n}"},"endpoint":"<>/api/items/<>/timers/<>","v":"1","method":"PUT","name":"Update timer (400 - Invalid date format)","headers":[{"key":"accept","active":true,"value":"application/json"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","preRequestScript":"","auth":{"authActive":true,"authType":"none"}},{"method":"PUT","auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>/timers/<>","params":[],"preRequestScript":"","v":"1","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","headers":[{"key":"accept","value":"application/json","active":true}],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}"},"name":"Update timer (400 - Stop is after start)"},{"body":{"contentType":null,"body":null},"name":"Stop timer","params":[],"v":"1","endpoint":"<>/api/timers/<>","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});","headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"PUT","preRequestScript":"","auth":{"authType":"none","authActive":true}},{"method":"PUT","name":"Stop timer (403 - Timer has already been stopped)","body":{"body":null,"contentType":null},"endpoint":"<>/api/timers/<>","params":[],"testScript":"\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});","headers":[{"key":"accept","active":true,"value":"application/json"}],"v":"1","auth":{"authType":"none","authActive":true},"preRequestScript":""},{"method":"PUT","params":[],"name":"Stop timer (404 - Timer not found)","auth":{"authType":"none","authActive":true},"preRequestScript":"","headers":[{"value":"application/json","active":true,"key":"accept"}],"v":"1","endpoint":"<>/api/timers/<>","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":null,"body":null}}]},{"v":1,"name":"Tags","folders":[],"requests":[{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag","params":[],"headers":[{"value":"application/json","active":true,"key":"accept"}],"method":"GET","auth":{"value":"","authType":"none","authActive":true,"key":"","addTo":"Headers"},"preRequestScript":"","testScript":"// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n \n});","body":{"contentType":null,"body":null}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag (404 - Tag not found)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"GET","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":null,"body":null}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag (400 - Invalid ID)","params":[],"headers":[{"active":true,"value":"application/json","key":"accept"}],"method":"GET","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"body":null,"contentType":null}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag","params":[],"headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"POST","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text\",\n \"person_id\": 0,\n \"color\": \"#FFFFFF\"\n}"}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag (no color provided)","params":[],"headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"POST","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text 2\",\n \"person_id\": 0\n}"}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag (400 - Invalid attributes)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"POST","auth":{"authType":"none","authActive":true},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"body":"{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}","contentType":"application/json"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag","params":[],"headers":[{"key":"accept","value":"application/json","active":true}],"method":"PUT","auth":{"authType":"none","authActive":true},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n});","body":{"body":"{\n \"text\": \"new updated tag text\"\n}","contentType":"application/json"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag (404 - Tag not found)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"new updated text\"\n}"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag (400 - Invalid attributes)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}"}}]}]} \ No newline at end of file +{"requests":[],"name":"MVP","v":1,"folders":[{"name":"Items","folders":[],"v":1,"requests":[{"endpoint":"<>/api/items/<>","method":"GET","auth":{"value":"","authType":"none","authActive":true,"key":"","addTo":"Headers"},"headers":[{"value":"application/json","active":true,"key":"accept"}],"body":{"contentType":null,"body":null},"v":"1","testScript":"// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});","name":"Get item","preRequestScript":"","params":[]},{"body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"headers":[{"key":"accept","active":true,"value":"application/json"}],"endpoint":"<>/api/items/<>","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","params":[],"name":"Get item (404 - Item not found)","method":"GET","v":"1","preRequestScript":""},{"body":{"body":null,"contentType":null},"headers":[{"active":true,"value":"application/json","key":"accept"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","v":"1","params":[],"method":"GET","auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>","preRequestScript":"","name":"Get item (400 - Invalid ID)"},{"params":[],"body":{"contentType":"application/json","body":"{\n \"text\": \"some text\"\n}"},"auth":{"authActive":true,"authType":"none"},"v":"1","name":"Create item","preRequestScript":"","endpoint":"<>/api/items","method":"POST","headers":[{"active":true,"key":"accept","value":"application/json"}],"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});"},{"preRequestScript":"","body":{"body":"{\n \"invalid\": \"something\"\n}","contentType":"application/json"},"method":"POST","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"v":"1","endpoint":"<>/api/items","auth":{"authType":"none","authActive":true},"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","name":"Create item (400 - Invalid attributes)"},{"endpoint":"<>/api/items/<>","name":"Update item","v":"1","body":{"body":"{\n \"text\": \"new updated text\"\n}","contentType":"application/json"},"headers":[{"key":"accept","value":"application/json","active":true}],"auth":{"authType":"none","authActive":true},"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});","params":[],"preRequestScript":"","method":"PUT"},{"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","name":"Update item (404 - Item not found)","endpoint":"<>/api/items/<>","preRequestScript":"","params":[],"auth":{"authActive":true,"authType":"none"},"v":"1","body":{"contentType":"application/json","body":"{\n \"text\": \"new updated text\"\n}"},"testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});"},{"headers":[{"active":true,"value":"application/json","key":"accept"}],"preRequestScript":"","endpoint":"<>/api/items/<>","method":"PUT","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","params":[],"body":{"contentType":"application/json","body":"{\n \"invalid\": \"invalid\"\n}"},"v":"1","name":"Update item (400 - Invalid attributes)","auth":{"authActive":true,"authType":"none"}}]},{"name":"Timers","v":1,"folders":[],"requests":[{"headers":[{"key":"accept","active":true,"value":"application/json"}],"body":{"body":null,"contentType":null},"endpoint":"<>/api/items/<>/timers","auth":{"authType":"none","authActive":true},"method":"GET","name":"Get timers","params":[],"v":"1","preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});"},{"preRequestScript":"","method":"GET","body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>/timers/<>","params":[],"name":"Get timer","headers":[{"key":"accept","active":true,"value":"application/json"}],"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});","v":"1"},{"auth":{"authType":"none","authActive":true},"params":[],"name":"Get timer (404 - Timer not found)","body":{"contentType":null,"body":null},"headers":[{"key":"accept","active":true,"value":"application/json"}],"endpoint":"<>/api/items/<>/timers/<>","v":"1","method":"GET","preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});"},{"auth":{"authType":"none","authActive":true},"body":{"body":null,"contentType":null},"params":[],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","headers":[{"key":"accept","value":"application/json","active":true}],"name":"Get timer (400 - Invalid ID)","endpoint":"<>/api/items/<>/timers/<>","method":"GET","preRequestScript":"","v":"1"},{"v":"1","body":{"body":"{\n \"start\": \"2023-01-11T17:40:44\"\n}","contentType":"application/json"},"headers":[{"active":true,"key":"accept","value":"application/json"}],"params":[],"method":"POST","name":"Create timer (custom start)","preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","endpoint":"<>/api/items/<>/timers","auth":{"authType":"none","authActive":true}},{"v":"1","headers":[{"value":"application/json","key":"accept","active":true}],"preRequestScript":"","body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"method":"POST","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","name":"Create timer (no start)","params":[],"endpoint":"<>/api/items/<>/timers"},{"method":"POST","preRequestScript":"","auth":{"authType":"none","authActive":true},"params":[],"v":"1","name":"Create timer (400 - Stop is after start) ","headers":[{"value":"application/json","active":true,"key":"accept"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","endpoint":"<>/api/items/<>/timers","body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}"}},{"method":"POST","v":"1","params":[],"auth":{"authType":"none","authActive":true},"headers":[{"value":"application/json","key":"accept","active":true}],"endpoint":"<>/api/items/<>/timers","preRequestScript":"","body":{"body":"{\n \"start\": \"2023-invalid-01\"\n}","contentType":"application/json"},"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","name":"Create timer (400 - Invalid date format) "},{"preRequestScript":"","auth":{"authActive":true,"authType":"none"},"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});","name":"Update timer","v":"1","endpoint":"<>/api/items/<>/timers/<>","headers":[{"value":"application/json","active":true,"key":"accept"}],"method":"PUT","params":[],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}"}},{"params":[],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-invalid-01\"\n}"},"endpoint":"<>/api/items/<>/timers/<>","v":"1","method":"PUT","name":"Update timer (400 - Invalid date format)","headers":[{"key":"accept","active":true,"value":"application/json"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","preRequestScript":"","auth":{"authActive":true,"authType":"none"}},{"method":"PUT","auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>/timers/<>","params":[],"preRequestScript":"","v":"1","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","headers":[{"key":"accept","value":"application/json","active":true}],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}"},"name":"Update timer (400 - Stop is after start)"},{"body":{"contentType":null,"body":null},"name":"Stop timer","params":[],"v":"1","endpoint":"<>/api/timers/<>","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});","headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"PUT","preRequestScript":"","auth":{"authType":"none","authActive":true}},{"method":"PUT","name":"Stop timer (403 - Timer has already been stopped)","body":{"body":null,"contentType":null},"endpoint":"<>/api/timers/<>","params":[],"testScript":"\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});","headers":[{"key":"accept","active":true,"value":"application/json"}],"v":"1","auth":{"authType":"none","authActive":true},"preRequestScript":""},{"method":"PUT","params":[],"name":"Stop timer (404 - Timer not found)","auth":{"authType":"none","authActive":true},"preRequestScript":"","headers":[{"value":"application/json","active":true,"key":"accept"}],"v":"1","endpoint":"<>/api/timers/<>","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":null,"body":null}}]},{"v":1,"name":"Tags","folders":[],"requests":[{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag","params":[],"headers":[{"value":"application/json","active":true,"key":"accept"}],"method":"GET","auth":{"value":"","authType":"none","authActive":true,"key":"","addTo":"Headers"},"preRequestScript":"","testScript":"// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n \n});","body":{"contentType":null,"body":null}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag (404 - Tag not found)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"GET","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":null,"body":null}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag (400 - Invalid ID)","params":[],"headers":[{"active":true,"value":"application/json","key":"accept"}],"method":"GET","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"body":null,"contentType":null}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag","params":[],"headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"POST","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text\",\n \"person_id\": 0,\n \"color\": \"#FFFFFF\"\n}"}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag (no color provided)","params":[],"headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"POST","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"another tag text\",\n \"person_id\": 0\n}"}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag (400 - Invalid attributes)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"POST","auth":{"authType":"none","authActive":true},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"body":"{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}","contentType":"application/json"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag","params":[],"headers":[{"key":"accept","value":"application/json","active":true}],"method":"PUT","auth":{"authType":"none","authActive":true},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n});","body":{"body":"{\n \"text\": \"new updated tag text\"\n}","contentType":"application/json"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag (404 - Tag not found)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"new updated text\"\n}"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag (400 - Invalid attributes)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}"}}]}]} \ No newline at end of file diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 5b440fa2..2d91e4a7 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -26,5 +26,5 @@ if Mix.env() == :dev do # Create tags {:ok, _tag} = - App.Tag.create_tag(%{text: "tag text", person_id: 0, color: "#FFFFFF"}) + App.Tag.create_tag(%{text: "random test", person_id: 0, color: "#FFFFFF"}) end From bd1a1117f85118fc29913158e9601b1aa69ced37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Tue, 24 Jan 2023 20:16:31 +0000 Subject: [PATCH 19/34] feat: Item now receives a tag array. #256 --- lib/api/item.ex | 96 ++++++++++++++++++++++++++++++++++++++++++++----- lib/app/tag.ex | 9 +++++ 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/lib/api/item.ex b/lib/api/item.ex index fac4cbde..22dcce4e 100644 --- a/lib/api/item.ex +++ b/lib/api/item.ex @@ -1,6 +1,7 @@ defmodule API.Item do use AppWeb, :controller alias App.Item + alias App.Tag import Ecto.Changeset def show(conn, %{"id" => id} = _params) do @@ -32,6 +33,7 @@ defmodule API.Item do end def create(conn, params) do + # Attributes to create item # Person_id will be changed when auth is added attrs = %{ @@ -40,15 +42,50 @@ defmodule API.Item do status: 2 } - case Item.create_item(attrs) do - # Successfully creates item - {:ok, %{model: item, version: _version}} -> - id_item = Map.take(item, [:id]) - json(conn, id_item) + # Get array of tag changeset, if supplied + tag_parameters_array = Map.get(params, "tags", []) + + # Item changeset, used to check if the the attributes are valid + item_changeset = Item.changeset(%Item{}, attrs) + + # Validating item, tag array and if any tag already exists + with true <- item_changeset.valid?, + {:ok, nil} <- invalid_tags_from_params_array(tag_parameters_array, attrs.person_id), + {:ok, nil} <- tags_that_already_exist(tag_parameters_array, attrs.person_id) + do + + {:ok, %{model: item, version: _version}} = Item.create_item(attrs) + + # Adding tags + Enum.each(tag_parameters_array, fn tag_attrs -> Tag.create_tag(tag_attrs) end) + + id_item = Map.take(item, [:id]) + json(conn, id_item) + else + # Error creating item (attributes) + false -> + errors = make_changeset_errors_readable(item_changeset) + + json( + conn |> put_status(400), + errors + ) + + # First tag that already exists + {:tag_already_exists, tag} -> + errors = %{ + code: 400, + message: " The tag \'" <> tag <> "\' already exists." + } + + json( + conn |> put_status(400), + errors + ) - # Error creating item - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) + # First tag that is invalid + {:invalid_tag, tag_changeset} -> + errors = make_changeset_errors_readable(tag_changeset) |> Map.put(:message, "At least one of the tags is malformed.") json( conn |> put_status(400), @@ -90,6 +127,49 @@ defmodule API.Item do end end + + defp invalid_tags_from_params_array(tag_parameters_array, person_id) do + + tag_changeset_array = Enum.map(tag_parameters_array, fn tag_params -> + # Add person_id and color if they are not specified + tag = %Tag{ + person_id: Map.get(tag_params, "person_id", person_id), + color: Map.get(tag_params, "color", App.Color.random()), + text: Map.get(tag_params, "text") + } + + # Return changeset + Tag.changeset(tag) + end) + + # Return first invalid tag changeset. If none is found, return nil + case Enum.find(tag_changeset_array, fn chs -> not chs.valid? end) do + nil -> {:ok, nil} + tag_changeset -> {:invalid_tag, tag_changeset} + end + + end + + + defp tags_that_already_exist(tag_parameters_array, person_id) do + if(length(tag_parameters_array) != 0) do + + # Retrieve tags texts from database + db_tags_text = Tag.list_person_tags_text(person_id) + tag_text_array = Enum.map(tag_parameters_array, fn tag -> Map.get(tag, "text", nil) end) + + # Return first tag that already exists in database. If none is found, return nil + case Enum.find(db_tags_text, fn x -> Enum.member?(tag_text_array, x) end) do + nil -> {:ok, nil} + tag -> {:tag_already_exists, tag} + end + + else + {:ok, nil} + end + end + + defp make_changeset_errors_readable(changeset) do errors = %{ code: 400, diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 946ff149..18f66ca3 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -81,6 +81,7 @@ defmodule App.Tag do def get_tag(id), do: Repo.get(Tag, id) + def list_person_tags(person_id) do Tag |> where(person_id: ^person_id) @@ -88,6 +89,14 @@ defmodule App.Tag do |> Repo.all() end + def list_person_tags_text(person_id) do + Tag + |> where(person_id: ^person_id) + |> order_by(:text) + |> select([t], t.text) + |> Repo.all() + end + def update_tag(%Tag{} = tag, attrs) do tag |> Tag.changeset(attrs) From 310fced049ccb77039e7e91ca70598d0ac90b983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Tue, 24 Jan 2023 20:36:05 +0000 Subject: [PATCH 20/34] feat: Adding test for tag. #256 --- test/app/tag_test.exs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/app/tag_test.exs b/test/app/tag_test.exs index a351640b..0facb9b5 100644 --- a/test/app/tag_test.exs +++ b/test/app/tag_test.exs @@ -59,4 +59,16 @@ defmodule App.TagTest do assert {:ok, _etc} = Tag.delete_tag(tag) end end + + describe "List tags" do + @valid_attrs %{text: "tag1", person_id: 1, color: "#FCA5A5"} + + test "list_person_tags_text/0 returns the tags texts" do + {:ok, tag} = Tag.create_tag(@valid_attrs) + tags_text_array = Tag.list_person_tags_text(@valid_attrs.person_id) + assert length(tags_text_array) == 1 + assert Enum.at(tags_text_array, 0) == @valid_attrs.text + end + end + end From 4c568a0659df6f3047536dfea52ffb53806e1c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Tue, 24 Jan 2023 21:01:52 +0000 Subject: [PATCH 21/34] feat: Adding tests for creating item with tags. #256 --- lib/api/item.ex | 2 +- test/api/item_test.exs | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/api/item.ex b/lib/api/item.ex index 22dcce4e..9bc12a5a 100644 --- a/lib/api/item.ex +++ b/lib/api/item.ex @@ -75,7 +75,7 @@ defmodule API.Item do {:tag_already_exists, tag} -> errors = %{ code: 400, - message: " The tag \'" <> tag <> "\' already exists." + message: "The tag \'" <> tag <> "\' already exists." } json( diff --git a/test/api/item_test.exs b/test/api/item_test.exs index b2d06cb1..4311033f 100644 --- a/test/api/item_test.exs +++ b/test/api/item_test.exs @@ -1,8 +1,12 @@ defmodule API.ItemTest do use AppWeb.ConnCase + alias App.Tag alias App.Item + @tag_text "tag text" @create_attrs %{person_id: 42, status: 0, text: "some text"} + @create_attrs_with_tags %{person_id: 42, status: 0, text: "some text", tags: [%{text: @tag_text}]} + @create_attrs_with_invalid_tags %{person_id: 42, status: 0, text: "some text", tags: [%{invalid: ""}]} @update_attrs %{person_id: 43, status: 0, text: "some updated text"} @invalid_attrs %{person_id: nil, status: nil, text: nil} @@ -31,13 +35,27 @@ defmodule API.ItemTest do test "a valid item", %{conn: conn} do conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs)) - assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") + assert json_response(conn, 200) + end + + test "a valid item with tags", %{conn: conn} do + conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) + assert json_response(conn, 200) + end - assert json_response(conn, 200)["status"] == - Map.get(@create_attrs, "status") + test "a valid item with tag that already exists", %{conn: conn} do + conn = post(conn, Routes.api_tag_path(conn, :create, %{text: @tag_text, person_id: @create_attrs_with_tags.person_id})) + conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) - assert json_response(conn, 200)["person_id"] == - Map.get(@create_attrs, "person_id") + assert json_response(conn, 400) + assert json_response(conn, 400)["message"] =~ "already exists" + end + + test "a valid item with an invalid tag", %{conn: conn} do + conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_invalid_tags)) + + assert json_response(conn, 400) + assert length(json_response(conn, 400)["errors"]["text"]) > 0 end test "an invalid item", %{conn: conn} do From d81a27b0dd48658d8476d9c5a092b8c17a7449c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Tue, 24 Jan 2023 22:13:31 +0000 Subject: [PATCH 22/34] feat: Associating created tags with the item being passed. #256 Removing warnings. --- lib/api/item.ex | 21 +++++++++++---------- test/api/item_test.exs | 1 - test/app/tag_test.exs | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/api/item.ex b/lib/api/item.ex index 9bc12a5a..9640f8e2 100644 --- a/lib/api/item.ex +++ b/lib/api/item.ex @@ -36,7 +36,7 @@ defmodule API.Item do # Attributes to create item # Person_id will be changed when auth is added - attrs = %{ + item_attrs = %{ text: Map.get(params, "text"), person_id: 0, status: 2 @@ -46,19 +46,19 @@ defmodule API.Item do tag_parameters_array = Map.get(params, "tags", []) # Item changeset, used to check if the the attributes are valid - item_changeset = Item.changeset(%Item{}, attrs) + item_changeset = Item.changeset(%Item{}, item_attrs) # Validating item, tag array and if any tag already exists with true <- item_changeset.valid?, - {:ok, nil} <- invalid_tags_from_params_array(tag_parameters_array, attrs.person_id), - {:ok, nil} <- tags_that_already_exist(tag_parameters_array, attrs.person_id) + {:ok, tag_changeset_array} <- invalid_tags_from_params_array(tag_parameters_array, item_attrs.person_id), + {:ok, nil} <- tags_that_already_exist(tag_parameters_array, item_attrs.person_id) do - {:ok, %{model: item, version: _version}} = Item.create_item(attrs) - - # Adding tags - Enum.each(tag_parameters_array, fn tag_attrs -> Tag.create_tag(tag_attrs) end) + # Creating item and tags and associate tags to item + attrs = Map.put(item_attrs, :tags, tag_changeset_array) + {:ok, %{model: item, version: _version}} = Item.create_item_with_tags(attrs) + # Return `id` of created item id_item = Map.take(item, [:id]) json(conn, id_item) else @@ -142,9 +142,10 @@ defmodule API.Item do Tag.changeset(tag) end) - # Return first invalid tag changeset. If none is found, return nil + # Return first invalid tag changeset. + # If none is found, return {:ok} and the array with tags converted to changesets case Enum.find(tag_changeset_array, fn chs -> not chs.valid? end) do - nil -> {:ok, nil} + nil -> {:ok, tag_changeset_array} tag_changeset -> {:invalid_tag, tag_changeset} end diff --git a/test/api/item_test.exs b/test/api/item_test.exs index 4311033f..936a6533 100644 --- a/test/api/item_test.exs +++ b/test/api/item_test.exs @@ -1,6 +1,5 @@ defmodule API.ItemTest do use AppWeb.ConnCase - alias App.Tag alias App.Item @tag_text "tag text" diff --git a/test/app/tag_test.exs b/test/app/tag_test.exs index 0facb9b5..fc961887 100644 --- a/test/app/tag_test.exs +++ b/test/app/tag_test.exs @@ -64,7 +64,7 @@ defmodule App.TagTest do @valid_attrs %{text: "tag1", person_id: 1, color: "#FCA5A5"} test "list_person_tags_text/0 returns the tags texts" do - {:ok, tag} = Tag.create_tag(@valid_attrs) + {:ok, _tag} = Tag.create_tag(@valid_attrs) tags_text_array = Tag.list_person_tags_text(@valid_attrs.person_id) assert length(tags_text_array) == 1 assert Enum.at(tags_text_array, 0) == @valid_attrs.text From 28cce16ad4148de6b89ada8233d8e6bc70513e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 25 Jan 2023 13:45:21 +0000 Subject: [PATCH 23/34] feat: Adding section for creating item with tags in API.md. #256 --- api.md | 487 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 479 insertions(+), 8 deletions(-) diff --git a/api.md b/api.md index 7f394498..c741bf3f 100644 --- a/api.md +++ b/api.md @@ -37,10 +37,16 @@ can also be done through our `REST API` - [6.5 Updating a `Timer`](#65-updating-a-timer) - [7. Adding `API.Tag`](#7-adding-apitag) - [7.1 Updating scope in `router.ex` and tests](#71-updating-scope-in-routerex-and-tests) - - [7.2 Implementing `API.Tag` CRUD operations](#72-implementing-apitag-crud-operations) - - [7.2.1 Adding tests](#721-adding-tests) - - [7.2.2 Adding `JSON` encoding and operations to `Tag` schema](#722-adding-json-encoding-and-operations-to-tag-schema) - - [7.2.3 Implementing `lib/api/tag.ex`](#723-implementing-libapitagex) + - [7.2 Implementing `API.Tag` CRUD operations](#72-implementing-apitag-crud-operations) + - [7.2.1 Adding tests](#721-adding-tests) + - [7.2.2 Adding `JSON` encoding and operations to `Tag` schema](#722-adding-json-encoding-and-operations-to-tag-schema) + - [7.2.3 Implementing `lib/api/tag.ex`](#723-implementing-libapitagex) + - [7.3 Allowing creating `item` with `tags`](#73-allowing-creating-item-with-tags) + - [7.3.1 Adding tests](#731-adding-tests) + - [7.3.2 Implementing `list_person_tags_text/0`](#732-implementing-list_person_tags_text0) + - [7.3.3 Updating `:create` in `lib/api/item.ex`](#733-updating-create-in-libapiitemex) + - [7.3.3.1 Creating `tag` validating functions](#7331-creating-tag-validating-functions) + - [7.3.3.2 Finishing up `lib/api/item.ex`'s `create` function](#7332-finishing-up-libapiitemexs-create-function) - [8. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#8-advancedautomated-api-testing-using-hoppscotch) - [8.0 `Hoppscotch` Setup](#80-hoppscotch-setup) - [8.1 Using `Hoppscotch`](#81-using-hoppscotch) @@ -1355,7 +1361,7 @@ The files should now look like this: - [`test/api/item_test.exs`](https://github.com/dwyl/mvp/blob/api_tags-%23256/test/api/item_test.exs) - [`test/api/timer_test.exs`](https://github.com/dwyl/mvp/blob/27962682ebc4302134a3335133a979739cdaf13e/test/api/timer_test.exs) -### 7.2 Implementing `API.Tag` CRUD operations +## 7.2 Implementing `API.Tag` CRUD operations Having changed the `router.ex` file to call an unimplemented `Tag` controller, @@ -1376,7 +1382,7 @@ e.g. `#FFFFFF`. If none is passed when created, a random one is generated. -#### 7.2.1 Adding tests +### 7.2.1 Adding tests Let's create the test file `test/api/tag_test.exs` @@ -1475,7 +1481,7 @@ In a similar fashion to `item` and `timer`, we are testing the API with the "Happy Path" and how it handles receiving invalid attributes. -#### 7.2.2 Adding `JSON` encoding and operations to `Tag` schema +### 7.2.2 Adding `JSON` encoding and operations to `Tag` schema In our `lib/app/tag.ex` file resides the `Tag` schema. To correctly encode and decode it in `JSON` format, @@ -1530,7 +1536,7 @@ We are using to validate if the input color string follows the `#XXXXXX` hex color format. -#### 7.2.3 Implementing `lib/api/tag.ex` +### 7.2.3 Implementing `lib/api/tag.ex` Now that we have the tests and the necessary changes implemented in `lib/app/tag.ex`, @@ -1671,6 +1677,471 @@ Finished in 1.7 seconds (1.6s async, 0.1s sync) Congratulations! 🎉 We've just implemented a CRUD `Tag` controller! +## 7.3 Allowing creating `item` with `tags` + +When designing an API, +we ought to take into account +how ["chatty"](https://github.com/dwyl/learn-api-design#avoid-chattiness-in-your-api) +it can be. +An API is considered **chatty** +is one that requires the consumer +to make distinct API calls +to make a specific action/access a resource. + +This has many advantages, +the main one being +that **less bandwitch is used**, +as we are reducing the number of requests. +Additionally, it *reduces time wasted* +when making multiple requests. + +Currently in our API, +if the user wanted to create an `item` and `tags`, +he would need to make *two requests*: +one to create an `item` +and another to create `tags`. + +We can make this better by +**allowing the user to pass an array of `tags`** +when creating an `item`. + +Let's do this! + +### 7.3.1 Adding tests + +Let's add our tests first. +We have two important constraints +that require validation before +creating the `item` and `tags`: + +- the `tags` need *to be valid*. +If not, inform the user. +- if any `tag` already exists for the given person, +inform the user. +- the `item` needs to be valid. + +We want all of these concerns to be passed +before we create an `item`, the `tags` +and associate them. + +With this in mind, +let's first start +by creating the tests needed to cover these scenarios. + +We are going to be making these changes +in the `:create` function of +`lib/api/item.ex`. +Therefore, open `test/api/item_test.exs` +and add the following tests +under the `describe "create"` test suite. + +```elixir + test "a valid item with tags", %{conn: conn} do + conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) + assert json_response(conn, 200) + end + + test "a valid item with tag that already exists", %{conn: conn} do + conn = post(conn, Routes.api_tag_path(conn, :create, %{text: @tag_text, person_id: @create_attrs_with_tags.person_id})) + conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) + + assert json_response(conn, 400) + assert json_response(conn, 400)["message"] =~ "already exists" + end + + test "a valid item with an invalid tag", %{conn: conn} do + conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_invalid_tags)) + + assert json_response(conn, 400) + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end +``` + +Each test pertains to the +constraints we've mentioned earlier. + +In addition to this, +we are going to need a function +to **list the tags text from a person that are already in the database**. +We are going to be using this function +to check to *compare the given tags* +with the ones that already exist in the database. + +For this, +open `test/app/tag_test.exs` +and add the following piece of code. + +```elixir + describe "List tags" do + @valid_attrs %{text: "tag1", person_id: 1, color: "#FCA5A5"} + + test "list_person_tags_text/0 returns the tags texts" do + {:ok, tag} = Tag.create_tag(@valid_attrs) + tags_text_array = Tag.list_person_tags_text(@valid_attrs.person_id) + assert length(tags_text_array) == 1 + assert Enum.at(tags_text_array, 0) == @valid_attrs.text + end + end +``` + +We are going to be implementing +the `list_person_tags_text/0` shortly. +This function will list the text of all the tags +for a given `person_id`. + +Now that we got the tests added, +it's time for us to start implementing! + +### 7.3.2 Implementing `list_person_tags_text/0` + +Let's start by +implementing `list_person_tags_text/0`. +Head over to `lib/app/tag.ex` +and add: + +```elixir + def list_person_tags_text(person_id) do + Tag + |> where(person_id: ^person_id) + |> order_by(:text) + |> select([t], t.text) + |> Repo.all() + end +``` + +This will return an array of tag texts +pertaining to the given `person_id`. + +This function will be used in the next section. + +### 7.3.3 Updating `:create` in `lib/api/item.ex` + +Now it's time for the bread and butter of this feature! +We are going to be making some changes +in `lib/api/item.ex`. + +Let's break down how we are going to implement this. +We want the `item` to be valid, +each `tag` to be valid, +and each `tag` unique (meaning it doesn't already exist). + +Open `lib/api/item.ex`, +locate the `create` function. +We are going to be implementing +the following structure. + +```elixir +def create(conn, params) do + + # Attributes to create item + # Person_id will be changed when auth is added + item_attrs = %{ + text: Map.get(params, "text"), + person_id: 0, + status: 2 + } + + # Get array of tag changeset, if supplied + tag_parameters_array = Map.get(params, "tags", []) + + # Item changeset, used to check if the the attributes are valid + item_changeset = Item.changeset(%Item{}, item_attrs) + + # Validating item, tag array and if any tag already exists + with true <- item_changeset.valid?, + {:ok, tag_changeset_array} <- invalid_tags_from_params_array(tag_parameters_array, item_attrs.person_id), + {:ok, nil} <- tags_that_already_exist(tag_parameters_array, item_attrs.person_id) + do + + # Creating item and tags and responding to the user with the newly created `id`. + + else + + # Handle any errors + + end + end +``` + +We are using a +[`with` control structure statement](https://www.openmymind.net/Elixirs-With-Statement/). +Using `with` is *super useful* +when we might use nested `case` statements +or in situations that cannot cleanly be piped together. +This is great since we are doing multiple validations +and we want to handle errors gracefully +if any of the validations fail. + +With `with` statements, +each expression is executed. +If all pattern-match as described above, +the user will create the `item` and `tag` +and respond with a success message. + +Otherwise, we can pattern-match +any errors in the `else` statement, +which can be derived from any of the three +expressions being evaluated inside `with`. + +You might have noticed we are evaluating three expressions: +- `true <- item_changeset.valid?`, +which uses the `item_changeset` to check if the passed attributes are valid. +- `{:ok, tag_changeset_array} <- invalid_tags_from_params_array(tag_parameters_array, item_attrs.person_id)`, +which calls `invalid_tags_from_params_array/2` which, +in turn, checks if any of the tags +passed in the request is invalid. +It returns an array of `tag` changesets if every `tag` is valid. +- `{:ok, nil} <- tags_that_already_exist(tag_parameters_array, item_attrs.person_id)`, +which calls `tags_that_already_exist/2` which, +in turn, check if any of the passed tags +already exists in the database. +`nil` is returned is none of the `tags` exists in the database. + +Let's implement these two validating functions! + +### 7.3.3.1 Creating `tag` validating functions + +Let's start with `invalid_tags_from_params_array/2`. +In `lib/api/item.ex`, +add the next private function. + +```elixir + defp invalid_tags_from_params_array(tag_parameters_array, person_id) do + + tag_changeset_array = Enum.map(tag_parameters_array, fn tag_params -> + # Add person_id and color if they are not specified + tag = %Tag{ + person_id: Map.get(tag_params, "person_id", person_id), + color: Map.get(tag_params, "color", App.Color.random()), + text: Map.get(tag_params, "text") + } + + # Return changeset + Tag.changeset(tag) + end) + + # Return first invalid tag changeset. + # If none is found, return {:ok} and the array with tags converted to changesets + case Enum.find(tag_changeset_array, fn chs -> not chs.valid? end) do + nil -> {:ok, tag_changeset_array} + tag_changeset -> {:invalid_tag, tag_changeset} + end + + end +``` + +In this function, we receive the `person_id` +and an array of tag parameters +(originated from the request body). +We are converting each `tag` param object in the array +to a **changeset**. +This will allow us to check if each param is valid or not. + +We *then* return the first invalid tag. +If none is found, we returned the `tag` changeset array. + +The reason we return a `tag` changeset array +if every `tag` is valid +is to later use when creating the `item` and `tags` +and associating the latter with the former +by calling `create_item_with_tags/1` +(located in `lib/app/tag.ex`). + +Let's implement the other function - +`tags_that_already_exist/2`. + +In the same file `lib/api/item.ex`: + +```elixir + defp tags_that_already_exist(tag_parameters_array, person_id) do + if(length(tag_parameters_array) != 0) do + + # Retrieve tags texts from database + db_tags_text = Tag.list_person_tags_text(person_id) + tag_text_array = Enum.map(tag_parameters_array, fn tag -> Map.get(tag, "text", nil) end) + + # Return first tag that already exists in database. If none is found, return nil + case Enum.find(db_tags_text, fn x -> Enum.member?(tag_text_array, x) end) do + nil -> {:ok, nil} + tag -> {:tag_already_exists, tag} + end + + else + {:ok, nil} + end + end +``` + +In this private function +we *fetch the tags from database* from the given `person_id` +and check if any of the request `tags` +that were passed in the request +*already exist in the database*. + +If one is found, it is returned as an error. +If not, `nil` is returned, +meaning the passed `tags` are unique to the `person_id`. + +### 7.3.3.2 Finishing up `lib/api/item.ex`'s `create` function + +Now that we know the possible returns +each function used in the `with` expression, +we can implement the rest of the function. + +Check out the final version of this function, +in `lib/api/item.ex`. + +```elixir + def create(conn, params) do + + # Attributes to create item + # Person_id will be changed when auth is added + item_attrs = %{ + text: Map.get(params, "text"), + person_id: 0, + status: 2 + } + + # Get array of tag changeset, if supplied + tag_parameters_array = Map.get(params, "tags", []) + + # Item changeset, used to check if the the attributes are valid + item_changeset = Item.changeset(%Item{}, item_attrs) + + # Validating item, tag array and if any tag already exists + with true <- item_changeset.valid?, + {:ok, tag_changeset_array} <- invalid_tags_from_params_array(tag_parameters_array, item_attrs.person_id), + {:ok, nil} <- tags_that_already_exist(tag_parameters_array, item_attrs.person_id) + do + + # Creating item and tags and associate tags to item + attrs = Map.put(item_attrs, :tags, tag_changeset_array) + {:ok, %{model: item, version: _version}} = Item.create_item_with_tags(attrs) + + # Return `id` of created item + id_item = Map.take(item, [:id]) + json(conn, id_item) + else + # Error creating item (attributes) + false -> + errors = make_changeset_errors_readable(item_changeset) + + json( + conn |> put_status(400), + errors + ) + + # First tag that already exists + {:tag_already_exists, tag} -> + errors = %{ + code: 400, + message: "The tag \'" <> tag <> "\' already exists." + } + + json( + conn |> put_status(400), + errors + ) + + # First tag that is invalid + {:invalid_tag, tag_changeset} -> + errors = make_changeset_errors_readable(tag_changeset) |> Map.put(:message, "At least one of the tags is malformed.") + + json( + conn |> put_status(400), + errors + ) + end + end +``` + +If all validations are successfully met, +we *use* the `tag` changeset array +so we can create the `item`, `tags` +and associate them +by calling `Item.create_item_with_tags/1` function +in `lib/app/item.ex`. + +After this, we return the `id` of the created `item`. + +```elixir + # Creating item and tags and associate tags to item + attrs = Map.put(item_attrs, :tags, tag_changeset_array) + {:ok, %{model: item, version: _version}} = Item.create_item_with_tags(attrs) + + # Return `id` of created item + id_item = Map.take(item, [:id]) + json(conn, id_item) +``` + +And lastly, +if these validations in the `with` statement +are not correctly matched, +we pattern-match the possible return scenarios. + +```elixir + # Error creating item (attributes) + false -> + errors = make_changeset_errors_readable(item_changeset) + + json( + conn |> put_status(400), + errors + ) + + # First tag that already exists + {:tag_already_exists, tag} -> + errors = %{ + code: 400, + message: "The tag \'" <> tag <> "\' already exists." + } + + json( + conn |> put_status(400), + errors + ) + + # First tag that is invalid + {:invalid_tag, tag_changeset} -> + errors = make_changeset_errors_readable(tag_changeset) |> Map.put(:message, "At least one of the tags is malformed.") + + json( + conn |> put_status(400), + errors + ) +``` + +If `false` is returned, +it's because `item_changeset.valid?` returns as such. + +If `{:tag_already_exists, tag}` is returned, +it's because `tags_that_already_exist/2` +returns the error and the `tag` text that already exists. + +If `{:invalid_tag, tag_changeset}` is returned, +it means `invalid_tags_from_params_array/2` +returned the error and the `tag` changeset that is invalid. + +In **all of these scenarios**, +an error is returned to the user +and a meaningful error message is created. + +And you are done! 🎉 +You've just *extended* the feature of +creating an `item` by allowing the user to +*also* add the `tag` array, if he so wishes. + +If you run `mix test`, +all tests should pass! + +```sh +Finished in 1.9 seconds (1.7s async, 0.1s sync) +114 tests, 1 failure + +Randomized with seed 907513 +``` + + # 8. _Advanced/Automated_ `API` Testing Using `Hoppscotch` `API` testing is an essential part From 202d4d69577bf580fa0055d610c8412f98a76adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 25 Jan 2023 14:04:03 +0000 Subject: [PATCH 24/34] fix: Fixing typos on API.md. --- api.md | 52 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/api.md b/api.md index c741bf3f..585839b2 100644 --- a/api.md +++ b/api.md @@ -45,8 +45,8 @@ can also be done through our `REST API` - [7.3.1 Adding tests](#731-adding-tests) - [7.3.2 Implementing `list_person_tags_text/0`](#732-implementing-list_person_tags_text0) - [7.3.3 Updating `:create` in `lib/api/item.ex`](#733-updating-create-in-libapiitemex) - - [7.3.3.1 Creating `tag` validating functions](#7331-creating-tag-validating-functions) - - [7.3.3.2 Finishing up `lib/api/item.ex`'s `create` function](#7332-finishing-up-libapiitemexs-create-function) + - [7.3.3.1 Creating `tag` validating functions](#7331-creating-tag-validating-functions) + - [7.3.3.2 Finishing up `lib/api/item.ex`'s `create` function](#7332-finishing-up-libapiitemexs-create-function) - [8. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#8-advancedautomated-api-testing-using-hoppscotch) - [8.0 `Hoppscotch` Setup](#80-hoppscotch-setup) - [8.1 Using `Hoppscotch`](#81-using-hoppscotch) @@ -1684,11 +1684,11 @@ we ought to take into account how ["chatty"](https://github.com/dwyl/learn-api-design#avoid-chattiness-in-your-api) it can be. An API is considered **chatty** -is one that requires the consumer +if it requires the consumer to make distinct API calls to make a specific action/access a resource. -This has many advantages, +Reducing chattiness has many advantages, the main one being that **less bandwitch is used**, as we are reducing the number of requests. @@ -1710,7 +1710,7 @@ Let's do this! ### 7.3.1 Adding tests Let's add our tests first. -We have two important constraints +We have three important constraints that require validation before creating the `item` and `tags`: @@ -1720,22 +1720,29 @@ If not, inform the user. inform the user. - the `item` needs to be valid. -We want all of these concerns to be passed -before we create an `item`, the `tags` -and associate them. +We want all of these concerns to be validated +before creating an `item`, +the `tags` +and associating them. With this in mind, let's first start by creating the tests needed to cover these scenarios. -We are going to be making these changes +We are going to be making changes in the `:create` function of `lib/api/item.ex`. + Therefore, open `test/api/item_test.exs` and add the following tests under the `describe "create"` test suite. ```elixir + @tag_text "tag text" + @create_attrs %{person_id: 42, status: 0, text: "some text"} + @create_attrs_with_tags %{person_id: 42, status: 0, text: "some text", tags: [%{text: @tag_text}]} + @create_attrs_with_invalid_tags %{person_id: 42, status: 0, text: "some text", tags: [%{invalid: ""}]} + test "a valid item with tags", %{conn: conn} do conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) assert json_response(conn, 200) @@ -1757,6 +1764,9 @@ under the `describe "create"` test suite. end ``` +e.g. +[`test/api/item_test.exs`](https://github.com/dwyl/mvp/blob/4c568a0659df6f3047536dfea52ffb53806e1c9e/test/api/item_test.exs) + Each test pertains to the constraints we've mentioned earlier. @@ -1764,7 +1774,7 @@ In addition to this, we are going to need a function to **list the tags text from a person that are already in the database**. We are going to be using this function -to check to *compare the given tags* +to *compare the given tags* with the ones that already exist in the database. For this, @@ -1821,6 +1831,7 @@ We are going to be making some changes in `lib/api/item.ex`. Let's break down how we are going to implement this. + We want the `item` to be valid, each `tag` to be valid, and each `tag` unique (meaning it doesn't already exist). @@ -1874,24 +1885,24 @@ if any of the validations fail. With `with` statements, each expression is executed. -If all pattern-match as described above, +If all "pattern-match" as described above, the user will create the `item` and `tag` and respond with a success message. -Otherwise, we can pattern-match +Otherwise, we can "pattern-match" any errors in the `else` statement, which can be derived from any of the three expressions being evaluated inside `with`. You might have noticed we are evaluating three expressions: -- `true <- item_changeset.valid?`, +- **`true <- item_changeset.valid?`**, which uses the `item_changeset` to check if the passed attributes are valid. -- `{:ok, tag_changeset_array} <- invalid_tags_from_params_array(tag_parameters_array, item_attrs.person_id)`, +- **`{:ok, tag_changeset_array} <- invalid_tags_from_params_array(tag_parameters_array, item_attrs.person_id)`**, which calls `invalid_tags_from_params_array/2` which, in turn, checks if any of the tags -passed in the request is invalid. +passed in the request are invalid. It returns an array of `tag` changesets if every `tag` is valid. -- `{:ok, nil} <- tags_that_already_exist(tag_parameters_array, item_attrs.person_id)`, +- **`{:ok, nil} <- tags_that_already_exist(tag_parameters_array, item_attrs.person_id)`**, which calls `tags_that_already_exist/2` which, in turn, check if any of the passed tags already exists in the database. @@ -1899,7 +1910,7 @@ already exists in the database. Let's implement these two validating functions! -### 7.3.3.1 Creating `tag` validating functions +#### 7.3.3.1 Creating `tag` validating functions Let's start with `invalid_tags_from_params_array/2`. In `lib/api/item.ex`, @@ -1938,11 +1949,11 @@ to a **changeset**. This will allow us to check if each param is valid or not. We *then* return the first invalid tag. -If none is found, we returned the `tag` changeset array. +If none is found, we return the `tag` changeset array. The reason we return a `tag` changeset array if every `tag` is valid -is to later use when creating the `item` and `tags` +is to later use it when creating the `item` and `tags` and associating the latter with the former by calling `create_item_with_tags/1` (located in `lib/app/tag.ex`). @@ -1982,7 +1993,7 @@ If one is found, it is returned as an error. If not, `nil` is returned, meaning the passed `tags` are unique to the `person_id`. -### 7.3.3.2 Finishing up `lib/api/item.ex`'s `create` function +#### 7.3.3.2 Finishing up `lib/api/item.ex`'s `create` function Now that we know the possible returns each function used in the `with` expression, @@ -2127,6 +2138,7 @@ an error is returned to the user and a meaningful error message is created. And you are done! 🎉 + You've just *extended* the feature of creating an `item` by allowing the user to *also* add the `tag` array, if he so wishes. From 19acedddba2a1f4cc43d511a34d9b4be222685a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 25 Jan 2023 14:10:41 +0000 Subject: [PATCH 25/34] fix: Mix format. --- lib/api/item.ex | 52 ++++++++++++++++++++++-------------------- lib/app/tag.ex | 1 - test/api/item_test.exs | 37 +++++++++++++++++++++++++----- test/app/tag_test.exs | 1 - 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/lib/api/item.ex b/lib/api/item.ex index 9640f8e2..4aa4a6a2 100644 --- a/lib/api/item.ex +++ b/lib/api/item.ex @@ -33,7 +33,6 @@ defmodule API.Item do end def create(conn, params) do - # Attributes to create item # Person_id will be changed when auth is added item_attrs = %{ @@ -50,13 +49,18 @@ defmodule API.Item do # Validating item, tag array and if any tag already exists with true <- item_changeset.valid?, - {:ok, tag_changeset_array} <- invalid_tags_from_params_array(tag_parameters_array, item_attrs.person_id), - {:ok, nil} <- tags_that_already_exist(tag_parameters_array, item_attrs.person_id) - do - + {:ok, tag_changeset_array} <- + invalid_tags_from_params_array( + tag_parameters_array, + item_attrs.person_id + ), + {:ok, nil} <- + tags_that_already_exist(tag_parameters_array, item_attrs.person_id) do # Creating item and tags and associate tags to item attrs = Map.put(item_attrs, :tags, tag_changeset_array) - {:ok, %{model: item, version: _version}} = Item.create_item_with_tags(attrs) + + {:ok, %{model: item, version: _version}} = + Item.create_item_with_tags(attrs) # Return `id` of created item id_item = Map.take(item, [:id]) @@ -85,7 +89,9 @@ defmodule API.Item do # First tag that is invalid {:invalid_tag, tag_changeset} -> - errors = make_changeset_errors_readable(tag_changeset) |> Map.put(:message, "At least one of the tags is malformed.") + errors = + make_changeset_errors_readable(tag_changeset) + |> Map.put(:message, "At least one of the tags is malformed.") json( conn |> put_status(400), @@ -127,20 +133,19 @@ defmodule API.Item do end end - defp invalid_tags_from_params_array(tag_parameters_array, person_id) do + tag_changeset_array = + Enum.map(tag_parameters_array, fn tag_params -> + # Add person_id and color if they are not specified + tag = %Tag{ + person_id: Map.get(tag_params, "person_id", person_id), + color: Map.get(tag_params, "color", App.Color.random()), + text: Map.get(tag_params, "text") + } - tag_changeset_array = Enum.map(tag_parameters_array, fn tag_params -> - # Add person_id and color if they are not specified - tag = %Tag{ - person_id: Map.get(tag_params, "person_id", person_id), - color: Map.get(tag_params, "color", App.Color.random()), - text: Map.get(tag_params, "text") - } - - # Return changeset - Tag.changeset(tag) - end) + # Return changeset + Tag.changeset(tag) + end) # Return first invalid tag changeset. # If none is found, return {:ok} and the array with tags converted to changesets @@ -148,29 +153,26 @@ defmodule API.Item do nil -> {:ok, tag_changeset_array} tag_changeset -> {:invalid_tag, tag_changeset} end - end - defp tags_that_already_exist(tag_parameters_array, person_id) do if(length(tag_parameters_array) != 0) do - # Retrieve tags texts from database db_tags_text = Tag.list_person_tags_text(person_id) - tag_text_array = Enum.map(tag_parameters_array, fn tag -> Map.get(tag, "text", nil) end) + + tag_text_array = + Enum.map(tag_parameters_array, fn tag -> Map.get(tag, "text", nil) end) # Return first tag that already exists in database. If none is found, return nil case Enum.find(db_tags_text, fn x -> Enum.member?(tag_text_array, x) end) do nil -> {:ok, nil} tag -> {:tag_already_exists, tag} end - else {:ok, nil} end end - defp make_changeset_errors_readable(changeset) do errors = %{ code: 400, diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 18f66ca3..547eea00 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -81,7 +81,6 @@ defmodule App.Tag do def get_tag(id), do: Repo.get(Tag, id) - def list_person_tags(person_id) do Tag |> where(person_id: ^person_id) diff --git a/test/api/item_test.exs b/test/api/item_test.exs index 936a6533..420357d0 100644 --- a/test/api/item_test.exs +++ b/test/api/item_test.exs @@ -4,8 +4,18 @@ defmodule API.ItemTest do @tag_text "tag text" @create_attrs %{person_id: 42, status: 0, text: "some text"} - @create_attrs_with_tags %{person_id: 42, status: 0, text: "some text", tags: [%{text: @tag_text}]} - @create_attrs_with_invalid_tags %{person_id: 42, status: 0, text: "some text", tags: [%{invalid: ""}]} + @create_attrs_with_tags %{ + person_id: 42, + status: 0, + text: "some text", + tags: [%{text: @tag_text}] + } + @create_attrs_with_invalid_tags %{ + person_id: 42, + status: 0, + text: "some text", + tags: [%{invalid: ""}] + } @update_attrs %{person_id: 43, status: 0, text: "some updated text"} @invalid_attrs %{person_id: nil, status: nil, text: nil} @@ -38,20 +48,35 @@ defmodule API.ItemTest do end test "a valid item with tags", %{conn: conn} do - conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) + conn = + post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) + assert json_response(conn, 200) end test "a valid item with tag that already exists", %{conn: conn} do - conn = post(conn, Routes.api_tag_path(conn, :create, %{text: @tag_text, person_id: @create_attrs_with_tags.person_id})) - conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) + conn = + post( + conn, + Routes.api_tag_path(conn, :create, %{ + text: @tag_text, + person_id: @create_attrs_with_tags.person_id + }) + ) + + conn = + post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_tags)) assert json_response(conn, 400) assert json_response(conn, 400)["message"] =~ "already exists" end test "a valid item with an invalid tag", %{conn: conn} do - conn = post(conn, Routes.api_item_path(conn, :create, @create_attrs_with_invalid_tags)) + conn = + post( + conn, + Routes.api_item_path(conn, :create, @create_attrs_with_invalid_tags) + ) assert json_response(conn, 400) assert length(json_response(conn, 400)["errors"]["text"]) > 0 diff --git a/test/app/tag_test.exs b/test/app/tag_test.exs index fc961887..f45c490d 100644 --- a/test/app/tag_test.exs +++ b/test/app/tag_test.exs @@ -70,5 +70,4 @@ defmodule App.TagTest do assert Enum.at(tags_text_array, 0) == @valid_attrs.text end end - end From 2bc3204ecc5b70d7a2d88e989c1f80fde4d775cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 25 Jan 2023 14:17:10 +0000 Subject: [PATCH 26/34] feat: Adding item with tags tests to CI. #256 --- lib/api/MVP.json | 853 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 852 insertions(+), 1 deletion(-) diff --git a/lib/api/MVP.json b/lib/api/MVP.json index e88d740c..945c544d 100644 --- a/lib/api/MVP.json +++ b/lib/api/MVP.json @@ -1 +1,852 @@ -{"requests":[],"name":"MVP","v":1,"folders":[{"name":"Items","folders":[],"v":1,"requests":[{"endpoint":"<>/api/items/<>","method":"GET","auth":{"value":"","authType":"none","authActive":true,"key":"","addTo":"Headers"},"headers":[{"value":"application/json","active":true,"key":"accept"}],"body":{"contentType":null,"body":null},"v":"1","testScript":"// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});","name":"Get item","preRequestScript":"","params":[]},{"body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"headers":[{"key":"accept","active":true,"value":"application/json"}],"endpoint":"<>/api/items/<>","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","params":[],"name":"Get item (404 - Item not found)","method":"GET","v":"1","preRequestScript":""},{"body":{"body":null,"contentType":null},"headers":[{"active":true,"value":"application/json","key":"accept"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","v":"1","params":[],"method":"GET","auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>","preRequestScript":"","name":"Get item (400 - Invalid ID)"},{"params":[],"body":{"contentType":"application/json","body":"{\n \"text\": \"some text\"\n}"},"auth":{"authActive":true,"authType":"none"},"v":"1","name":"Create item","preRequestScript":"","endpoint":"<>/api/items","method":"POST","headers":[{"active":true,"key":"accept","value":"application/json"}],"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});"},{"preRequestScript":"","body":{"body":"{\n \"invalid\": \"something\"\n}","contentType":"application/json"},"method":"POST","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"v":"1","endpoint":"<>/api/items","auth":{"authType":"none","authActive":true},"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","name":"Create item (400 - Invalid attributes)"},{"endpoint":"<>/api/items/<>","name":"Update item","v":"1","body":{"body":"{\n \"text\": \"new updated text\"\n}","contentType":"application/json"},"headers":[{"key":"accept","value":"application/json","active":true}],"auth":{"authType":"none","authActive":true},"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});","params":[],"preRequestScript":"","method":"PUT"},{"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","name":"Update item (404 - Item not found)","endpoint":"<>/api/items/<>","preRequestScript":"","params":[],"auth":{"authActive":true,"authType":"none"},"v":"1","body":{"contentType":"application/json","body":"{\n \"text\": \"new updated text\"\n}"},"testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});"},{"headers":[{"active":true,"value":"application/json","key":"accept"}],"preRequestScript":"","endpoint":"<>/api/items/<>","method":"PUT","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","params":[],"body":{"contentType":"application/json","body":"{\n \"invalid\": \"invalid\"\n}"},"v":"1","name":"Update item (400 - Invalid attributes)","auth":{"authActive":true,"authType":"none"}}]},{"name":"Timers","v":1,"folders":[],"requests":[{"headers":[{"key":"accept","active":true,"value":"application/json"}],"body":{"body":null,"contentType":null},"endpoint":"<>/api/items/<>/timers","auth":{"authType":"none","authActive":true},"method":"GET","name":"Get timers","params":[],"v":"1","preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});"},{"preRequestScript":"","method":"GET","body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>/timers/<>","params":[],"name":"Get timer","headers":[{"key":"accept","active":true,"value":"application/json"}],"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});","v":"1"},{"auth":{"authType":"none","authActive":true},"params":[],"name":"Get timer (404 - Timer not found)","body":{"contentType":null,"body":null},"headers":[{"key":"accept","active":true,"value":"application/json"}],"endpoint":"<>/api/items/<>/timers/<>","v":"1","method":"GET","preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});"},{"auth":{"authType":"none","authActive":true},"body":{"body":null,"contentType":null},"params":[],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","headers":[{"key":"accept","value":"application/json","active":true}],"name":"Get timer (400 - Invalid ID)","endpoint":"<>/api/items/<>/timers/<>","method":"GET","preRequestScript":"","v":"1"},{"v":"1","body":{"body":"{\n \"start\": \"2023-01-11T17:40:44\"\n}","contentType":"application/json"},"headers":[{"active":true,"key":"accept","value":"application/json"}],"params":[],"method":"POST","name":"Create timer (custom start)","preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","endpoint":"<>/api/items/<>/timers","auth":{"authType":"none","authActive":true}},{"v":"1","headers":[{"value":"application/json","key":"accept","active":true}],"preRequestScript":"","body":{"contentType":null,"body":null},"auth":{"authActive":true,"authType":"none"},"method":"POST","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","name":"Create timer (no start)","params":[],"endpoint":"<>/api/items/<>/timers"},{"method":"POST","preRequestScript":"","auth":{"authType":"none","authActive":true},"params":[],"v":"1","name":"Create timer (400 - Stop is after start) ","headers":[{"value":"application/json","active":true,"key":"accept"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","endpoint":"<>/api/items/<>/timers","body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}"}},{"method":"POST","v":"1","params":[],"auth":{"authType":"none","authActive":true},"headers":[{"value":"application/json","key":"accept","active":true}],"endpoint":"<>/api/items/<>/timers","preRequestScript":"","body":{"body":"{\n \"start\": \"2023-invalid-01\"\n}","contentType":"application/json"},"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","name":"Create timer (400 - Invalid date format) "},{"preRequestScript":"","auth":{"authActive":true,"authType":"none"},"testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});","name":"Update timer","v":"1","endpoint":"<>/api/items/<>/timers/<>","headers":[{"value":"application/json","active":true,"key":"accept"}],"method":"PUT","params":[],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}"}},{"params":[],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-invalid-01\"\n}"},"endpoint":"<>/api/items/<>/timers/<>","v":"1","method":"PUT","name":"Update timer (400 - Invalid date format)","headers":[{"key":"accept","active":true,"value":"application/json"}],"testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","preRequestScript":"","auth":{"authActive":true,"authType":"none"}},{"method":"PUT","auth":{"authActive":true,"authType":"none"},"endpoint":"<>/api/items/<>/timers/<>","params":[],"preRequestScript":"","v":"1","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","headers":[{"key":"accept","value":"application/json","active":true}],"body":{"contentType":"application/json","body":"{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}"},"name":"Update timer (400 - Stop is after start)"},{"body":{"contentType":null,"body":null},"name":"Stop timer","params":[],"v":"1","endpoint":"<>/api/timers/<>","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});","headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"PUT","preRequestScript":"","auth":{"authType":"none","authActive":true}},{"method":"PUT","name":"Stop timer (403 - Timer has already been stopped)","body":{"body":null,"contentType":null},"endpoint":"<>/api/timers/<>","params":[],"testScript":"\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});","headers":[{"key":"accept","active":true,"value":"application/json"}],"v":"1","auth":{"authType":"none","authActive":true},"preRequestScript":""},{"method":"PUT","params":[],"name":"Stop timer (404 - Timer not found)","auth":{"authType":"none","authActive":true},"preRequestScript":"","headers":[{"value":"application/json","active":true,"key":"accept"}],"v":"1","endpoint":"<>/api/timers/<>","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":null,"body":null}}]},{"v":1,"name":"Tags","folders":[],"requests":[{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag","params":[],"headers":[{"value":"application/json","active":true,"key":"accept"}],"method":"GET","auth":{"value":"","authType":"none","authActive":true,"key":"","addTo":"Headers"},"preRequestScript":"","testScript":"// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n \n});","body":{"contentType":null,"body":null}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag (404 - Tag not found)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"GET","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":null,"body":null}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Get tag (400 - Invalid ID)","params":[],"headers":[{"active":true,"value":"application/json","key":"accept"}],"method":"GET","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"body":null,"contentType":null}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag","params":[],"headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"POST","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text\",\n \"person_id\": 0,\n \"color\": \"#FFFFFF\"\n}"}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag (no color provided)","params":[],"headers":[{"active":true,"key":"accept","value":"application/json"}],"method":"POST","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"another tag text\",\n \"person_id\": 0\n}"}},{"v":"1","endpoint":"<>/api/tags","name":"Create tag (400 - Invalid attributes)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"POST","auth":{"authType":"none","authActive":true},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"body":"{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}","contentType":"application/json"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag","params":[],"headers":[{"key":"accept","value":"application/json","active":true}],"method":"PUT","auth":{"authType":"none","authActive":true},"preRequestScript":"","testScript":"// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n});","body":{"body":"{\n \"text\": \"new updated tag text\"\n}","contentType":"application/json"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag (404 - Tag not found)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"new updated text\"\n}"}},{"v":"1","endpoint":"<>/api/tags/<>","name":"Update tag (400 - Invalid attributes)","params":[],"headers":[{"key":"accept","active":true,"value":"application/json"}],"method":"PUT","auth":{"authActive":true,"authType":"none"},"preRequestScript":"","testScript":"// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});","body":{"contentType":"application/json","body":"{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}"}}]}]} \ No newline at end of file +[ + { + "requests": [], + "name": "MVP", + "v": 1, + "folders": [ + { + "name": "Items", + "folders": [], + "v": 1, + "requests": [ + { + "endpoint": "<>/api/items/<>", + "method": "GET", + "auth": { + "value": "", + "authType": "none", + "authActive": true, + "key": "", + "addTo": "Headers" + }, + "headers": [ + { + "value": "application/json", + "active": true, + "key": "accept" + } + ], + "body": { + "contentType": null, + "body": null + }, + "v": "1", + "testScript": "// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});", + "name": "Get item", + "preRequestScript": "", + "params": [] + }, + { + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authActive": true, + "authType": "none" + }, + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "endpoint": "<>/api/items/<>", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "params": [], + "name": "Get item (404 - Item not found)", + "method": "GET", + "v": "1", + "preRequestScript": "" + }, + { + "body": { + "body": null, + "contentType": null + }, + "headers": [ + { + "active": true, + "value": "application/json", + "key": "accept" + } + ], + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "v": "1", + "params": [], + "method": "GET", + "auth": { + "authActive": true, + "authType": "none" + }, + "endpoint": "<>/api/items/<>", + "preRequestScript": "", + "name": "Get item (400 - Invalid ID)" + }, + { + "v": "1", + "endpoint": "<>/api/items", + "name": "Create item", + "params": [], + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "method": "POST", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"aaa text\"\n}" + } + }, + { + "preRequestScript": "", + "body": { + "body": "{\n \"invalid\": \"something\"\n}", + "contentType": "application/json" + }, + "method": "POST", + "params": [], + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "v": "1", + "endpoint": "<>/api/items", + "auth": { + "authType": "none", + "authActive": true + }, + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "name": "Create item (400 - Invalid attributes)" + }, + { + "v": "1", + "endpoint": "<>/api/items", + "name": "Create item with tags", + "params": [], + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "method": "POST", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"text\": \"yet another tag\",\n \"color\": \"#FFFFFF\"\n }\n ]\n}" + } + }, + { + "v": "1", + "endpoint": "<>/api/items", + "name": "Create item with tags (400 - Tag already exists)", + "params": [], + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "method": "POST", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"text\": \"yet another tag\",\n \"color\": \"#FFFFFF\"\n }\n ]\n}" + } + }, + { + "v": "1", + "endpoint": "<>/api/items", + "name": "Create item with tags (400 - Tag is malformed)", + "params": [], + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "method": "POST", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"text\": \"yet another tag\",\n \"color\": \"#FFFFFF\"\n }\n ]\n}" + } + }, + { + "v": "1", + "endpoint": "<>/api/items", + "name": "Create item with tags (400 - Tag is malformed)", + "params": [], + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "method": "POST", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"invalid\": \"yet another tag\"\n }\n ]\n}" + } + }, + { + "endpoint": "<>/api/items/<>", + "name": "Update item", + "v": "1", + "body": { + "body": "{\n \"text\": \"new updated text\"\n}", + "contentType": "application/json" + }, + "headers": [ + { + "key": "accept", + "value": "application/json", + "active": true + } + ], + "auth": { + "authType": "none", + "authActive": true + }, + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});", + "params": [], + "preRequestScript": "", + "method": "PUT" + }, + { + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "method": "PUT", + "name": "Update item (404 - Item not found)", + "endpoint": "<>/api/items/<>", + "preRequestScript": "", + "params": [], + "auth": { + "authActive": true, + "authType": "none" + }, + "v": "1", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"new updated text\"\n}" + }, + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});" + } + ] + }, + { + "name": "Timers", + "v": 1, + "folders": [], + "requests": [ + { + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "body": { + "body": null, + "contentType": null + }, + "endpoint": "<>/api/items/<>/timers", + "auth": { + "authType": "none", + "authActive": true + }, + "method": "GET", + "name": "Get timers", + "params": [], + "v": "1", + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});" + }, + { + "preRequestScript": "", + "method": "GET", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authActive": true, + "authType": "none" + }, + "endpoint": "<>/api/items/<>/timers/<>", + "params": [], + "name": "Get timer", + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", + "v": "1" + }, + { + "auth": { + "authType": "none", + "authActive": true + }, + "params": [], + "name": "Get timer (404 - Timer not found)", + "body": { + "contentType": null, + "body": null + }, + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "endpoint": "<>/api/items/<>/timers/<>", + "v": "1", + "method": "GET", + "preRequestScript": "", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});" + }, + { + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "body": null, + "contentType": null + }, + "params": [], + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "headers": [ + { + "key": "accept", + "value": "application/json", + "active": true + } + ], + "name": "Get timer (400 - Invalid ID)", + "endpoint": "<>/api/items/<>/timers/<>", + "method": "GET", + "preRequestScript": "", + "v": "1" + }, + { + "v": "1", + "body": { + "body": "{\n \"start\": \"2023-01-11T17:40:44\"\n}", + "contentType": "application/json" + }, + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "params": [], + "method": "POST", + "name": "Create timer (custom start)", + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "endpoint": "<>/api/items/<>/timers", + "auth": { + "authType": "none", + "authActive": true + } + }, + { + "v": "1", + "headers": [ + { + "value": "application/json", + "key": "accept", + "active": true + } + ], + "preRequestScript": "", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authActive": true, + "authType": "none" + }, + "method": "POST", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "name": "Create timer (no start)", + "params": [], + "endpoint": "<>/api/items/<>/timers" + }, + { + "method": "POST", + "preRequestScript": "", + "auth": { + "authType": "none", + "authActive": true + }, + "params": [], + "v": "1", + "name": "Create timer (400 - Stop is after start) ", + "headers": [ + { + "value": "application/json", + "active": true, + "key": "accept" + } + ], + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "endpoint": "<>/api/items/<>/timers", + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}" + } + }, + { + "method": "POST", + "v": "1", + "params": [], + "auth": { + "authType": "none", + "authActive": true + }, + "headers": [ + { + "value": "application/json", + "key": "accept", + "active": true + } + ], + "endpoint": "<>/api/items/<>/timers", + "preRequestScript": "", + "body": { + "body": "{\n \"start\": \"2023-invalid-01\"\n}", + "contentType": "application/json" + }, + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "name": "Create timer (400 - Invalid date format) " + }, + { + "preRequestScript": "", + "auth": { + "authActive": true, + "authType": "none" + }, + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});", + "name": "Update timer", + "v": "1", + "endpoint": "<>/api/items/<>/timers/<>", + "headers": [ + { + "value": "application/json", + "active": true, + "key": "accept" + } + ], + "method": "PUT", + "params": [], + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}" + } + }, + { + "params": [], + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-invalid-01\"\n}" + }, + "endpoint": "<>/api/items/<>/timers/<>", + "v": "1", + "method": "PUT", + "name": "Update timer (400 - Invalid date format)", + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "preRequestScript": "", + "auth": { + "authActive": true, + "authType": "none" + } + }, + { + "method": "PUT", + "auth": { + "authActive": true, + "authType": "none" + }, + "endpoint": "<>/api/items/<>/timers/<>", + "params": [], + "preRequestScript": "", + "v": "1", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "headers": [ + { + "key": "accept", + "value": "application/json", + "active": true + } + ], + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}" + }, + "name": "Update timer (400 - Stop is after start)" + }, + { + "body": { + "contentType": null, + "body": null + }, + "name": "Stop timer", + "params": [], + "v": "1", + "endpoint": "<>/api/timers/<>", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "method": "PUT", + "preRequestScript": "", + "auth": { + "authType": "none", + "authActive": true + } + }, + { + "method": "PUT", + "name": "Stop timer (403 - Timer has already been stopped)", + "body": { + "body": null, + "contentType": null + }, + "endpoint": "<>/api/timers/<>", + "params": [], + "testScript": "\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});", + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "v": "1", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "" + }, + { + "method": "PUT", + "params": [], + "name": "Stop timer (404 - Timer not found)", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "", + "headers": [ + { + "value": "application/json", + "active": true, + "key": "accept" + } + ], + "v": "1", + "endpoint": "<>/api/timers/<>", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "body": { + "contentType": null, + "body": null + } + } + ] + }, + { + "v": 1, + "name": "Tags", + "folders": [], + "requests": [ + { + "v": "1", + "endpoint": "<>/api/tags/<>", + "name": "Get tag", + "params": [], + "headers": [ + { + "value": "application/json", + "active": true, + "key": "accept" + } + ], + "method": "GET", + "auth": { + "value": "", + "authType": "none", + "authActive": true, + "key": "", + "addTo": "Headers" + }, + "preRequestScript": "", + "testScript": "// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n \n});", + "body": { + "contentType": null, + "body": null + } + }, + { + "v": "1", + "endpoint": "<>/api/tags/<>", + "name": "Get tag (404 - Tag not found)", + "params": [], + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "method": "GET", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "body": { + "contentType": null, + "body": null + } + }, + { + "v": "1", + "endpoint": "<>/api/tags/<>", + "name": "Get tag (400 - Invalid ID)", + "params": [], + "headers": [ + { + "active": true, + "value": "application/json", + "key": "accept" + } + ], + "method": "GET", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "body": { + "body": null, + "contentType": null + } + }, + { + "v": "1", + "endpoint": "<>/api/tags", + "name": "Create tag", + "params": [], + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "method": "POST", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"tag text\",\n \"person_id\": 0,\n \"color\": \"#FFFFFF\"\n}" + } + }, + { + "v": "1", + "endpoint": "<>/api/tags", + "name": "Create tag (no color provided)", + "params": [], + "headers": [ + { + "active": true, + "key": "accept", + "value": "application/json" + } + ], + "method": "POST", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"another tag text\",\n \"person_id\": 0\n}" + } + }, + { + "v": "1", + "endpoint": "<>/api/tags", + "name": "Create tag (400 - Invalid attributes)", + "params": [], + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "method": "POST", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "body": { + "body": "{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}", + "contentType": "application/json" + } + }, + { + "v": "1", + "endpoint": "<>/api/tags/<>", + "name": "Update tag", + "params": [], + "headers": [ + { + "key": "accept", + "value": "application/json", + "active": true + } + ], + "method": "PUT", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n});", + "body": { + "body": "{\n \"text\": \"new updated tag text\"\n}", + "contentType": "application/json" + } + }, + { + "v": "1", + "endpoint": "<>/api/tags/<>", + "name": "Update tag (404 - Tag not found)", + "params": [], + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "method": "PUT", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"new updated text\"\n}" + } + }, + { + "v": "1", + "endpoint": "<>/api/tags/<>", + "name": "Update tag (400 - Invalid attributes)", + "params": [], + "headers": [ + { + "key": "accept", + "active": true, + "value": "application/json" + } + ], + "method": "PUT", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}" + } + } + ] + } + ] + } +] \ No newline at end of file From 720f0acb564b149c1f141ef2fba8d4c0a3790c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 26 Jan 2023 13:46:26 +0000 Subject: [PATCH 27/34] fix: Retrieving embedded tags on request. #256 --- lib/api/item.ex | 20 +++++++++++++++++--- lib/app/item.ex | 13 +++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/api/item.ex b/lib/api/item.ex index 4aa4a6a2..81f3bcf2 100644 --- a/lib/api/item.ex +++ b/lib/api/item.ex @@ -4,11 +4,15 @@ defmodule API.Item do alias App.Tag import Ecto.Changeset - def show(conn, %{"id" => id} = _params) do + def show(conn, %{"id" => id} = params) do + + embed = Map.get(params, "embed", "") + case Integer.parse(id) do # ID is an integer {id, _float} -> - case Item.get_item(id) do + retrieve_tags = embed =~ "tag" + case Item.get_item(id, retrieve_tags) do nil -> errors = %{ code: 404, @@ -18,7 +22,17 @@ defmodule API.Item do json(conn |> put_status(404), errors) item -> - json(conn, item) + if retrieve_tags do + json(conn, item) + else + # Since tags is Ecto.Association.NotLoaded, we have to remove it. + # By removing it, the object is no longer an Item struct, hence why encoding fails (@derive no longer applies). + # We need to remove the rest of the unwanted fields from the "now-map" object. + # + # Jason.Encode should do this instead of removing here. + item = Map.drop(item, [:tags, :timer, :__meta__, :__struct__, :inserted_at, :updated_at]) + json(conn, item) + end end # ID is not an integer diff --git a/lib/app/item.ex b/lib/app/item.ex index 3b644746..5156b712 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -7,7 +7,7 @@ defmodule App.Item do alias __MODULE__ require Logger - @derive {Jason.Encoder, only: [:id, :person_id, :status, :text]} + @derive {Jason.Encoder, except: [:__meta__, :__struct__, :timer, :inserted_at, :updated_at]} schema "items" do field :person_id, :integer field :status, :integer @@ -110,10 +110,15 @@ defmodule App.Item do iex> get_item(1313) nil """ - def get_item(id) do - Item + def get_item(id, preload_tags \\ false ) do + item = Item |> Repo.get(id) - |> Repo.preload(tags: from(t in Tag, order_by: t.text)) + + if(preload_tags == true) do + item |> Repo.preload(tags: from(t in Tag, order_by: t.text)) + else + item + end end @doc """ From a7a234479467e6c83bd837ca74a2f50433e992df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 26 Jan 2023 14:25:21 +0000 Subject: [PATCH 28/34] feat: Adding tests for tag embed. #256 --- lib/api/item.ex | 1 + test/api/item_test.exs | 9 +++++++++ test/app/item_test.exs | 11 ++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/api/item.ex b/lib/api/item.ex index 81f3bcf2..002be570 100644 --- a/lib/api/item.ex +++ b/lib/api/item.ex @@ -133,6 +133,7 @@ defmodule API.Item do case Item.update_item(item, %{text: new_text}) do # Successfully updates item {:ok, %{model: item, version: _version}} -> + item = Map.drop(item, [:tags, :timer, :__meta__, :__struct__, :inserted_at, :updated_at]) json(conn, item) # Error creating item diff --git a/test/api/item_test.exs b/test/api/item_test.exs index 420357d0..fa5e4308 100644 --- a/test/api/item_test.exs +++ b/test/api/item_test.exs @@ -28,6 +28,15 @@ defmodule API.ItemTest do assert json_response(conn, 200)["text"] == item.text end + test "specific item with tags", %{conn: conn} do + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) + conn = get(conn, Routes.api_item_path(conn, :show, item.id), %{"embed" => "tags"}) + + assert json_response(conn, 200)["id"] == item.id + assert json_response(conn, 200)["text"] == item.text + assert not is_nil(json_response(conn, 200)["tags"]) + end + test "not found item", %{conn: conn} do conn = get(conn, Routes.api_item_path(conn, :show, -1)) diff --git a/test/app/item_test.exs b/test/app/item_test.exs index 96cb48b6..00a5cb46 100644 --- a/test/app/item_test.exs +++ b/test/app/item_test.exs @@ -7,11 +7,20 @@ defmodule App.ItemTest do @update_attrs %{text: "some updated text", person_id: 1} @invalid_attrs %{text: nil} - test "get_item!/1 returns the item with given id" do + test "get_item!/2 returns the item with given id" do {:ok, %{model: item, version: _version}} = Item.create_item(@valid_attrs) assert Item.get_item!(item.id).text == item.text end + test "get_item/2 returns the item with given id with tags" do + {:ok, %{model: item, version: _version}} = Item.create_item(@valid_attrs) + + tags = Map.get(Item.get_item(item.id, true), :tags) + + assert Item.get_item(item.id, true).text == item.text + assert not is_nil(tags) + end + test "create_item/1 with valid data creates a item" do assert {:ok, %{model: item, version: _version}} = Item.create_item(@valid_attrs) From 5bf97c63ac462274633692f97b93c45f0badc275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 26 Jan 2023 16:24:12 +0000 Subject: [PATCH 29/34] feat: Adding section for retrieving tags from item in API.md. #256 --- api.md | 281 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) diff --git a/api.md b/api.md index 585839b2..0694fbc5 100644 --- a/api.md +++ b/api.md @@ -47,6 +47,10 @@ can also be done through our `REST API` - [7.3.3 Updating `:create` in `lib/api/item.ex`](#733-updating-create-in-libapiitemex) - [7.3.3.1 Creating `tag` validating functions](#7331-creating-tag-validating-functions) - [7.3.3.2 Finishing up `lib/api/item.ex`'s `create` function](#7332-finishing-up-libapiitemexs-create-function) + - [7.3 Fetching items with embedded tags](#73-fetching-items-with-embedded-tags) + - [7.3.1 Adding tests](#731-adding-tests-1) + - [7.3.2 Returning `item` with `tags` on endpoint](#732-returning-item-with-tags-on-endpoint) + - [7.3.3 A note about `Jason` encoding](#733-a-note-about-jason-encoding) - [8. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#8-advancedautomated-api-testing-using-hoppscotch) - [8.0 `Hoppscotch` Setup](#80-hoppscotch-setup) - [8.1 Using `Hoppscotch`](#81-using-hoppscotch) @@ -2154,6 +2158,283 @@ Randomized with seed 907513 ``` +## 7.3 Fetching items with embedded tags + +Now that people can create `items` with `tags`, +we can apply the same logic +and *allow the person to fetch `items` with the associated `tags`*. + +We can use **query parameters** +to know if the user wants to embed `tags` or not. +By limiting fields returned by the API, +we are +[following good `RESTful` practices](https://github.com/dwyl/learn-api-design#use-query-parameters-to-filter-sort-or-search-resources). + +Luckily, implementing this feature is quite simple! +Let's roll! 🍣 + +### 7.3.1 Adding tests + +Let's add our tests first. +We want the user to pass a *query param* +named `embed` with a value of `"tags"` +so the API returns the `item` with the associated `tags`. + +Inside `test/app/item_test.exs`, +add the following test. + +```elixir + test "get_item/2 returns the item with given id with tags" do + {:ok, %{model: item, version: _version}} = Item.create_item(@valid_attrs) + + tags = Map.get(Item.get_item(item.id, true), :tags) + + assert Item.get_item(item.id, true).text == item.text + assert not is_nil(tags) + end +``` + +The function `get_item/1` that is currently +in `lib/app/item.ex` will need to be changed +to return tags according to a second boolean parameter. +We will implement this change shortly. + +Let's add the test that will check the `:show` endpoint +and if tags are being returned when requested. +Inside `test/api/item_test.exs`, +add the following test. + +```elixir + test "specific item with tags", %{conn: conn} do + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) + conn = get(conn, Routes.api_item_path(conn, :show, item.id), %{"embed" => "tags"}) + + assert json_response(conn, 200)["id"] == item.id + assert json_response(conn, 200)["text"] == item.text + assert not is_nil(json_response(conn, 200)["tags"]) + end +``` + +We are passing a `embed` query parameter with `tags` value +and returning an array of `tags`. + +Now that we have the tests added, +let's implement the needed changes for these to pass! + +### 7.3.2 Returning `item` with `tags` on endpoint + +It's time to implement the changes! +Let's start by changing the function `get_item/1` +in `lib/app/item.ex`. + +Change it so it looks like the following. + +```elixir + def get_item(id, preload_tags \\ false ) do + item = Item + |> Repo.get(id) + + if(preload_tags == true) do + item |> Repo.preload(tags: from(t in Tag, order_by: t.text)) + else + item + end + end +``` + +We are also going to change how to encode the schema. +Change the `@derive` annotation to the following. + +```elixir + @derive {Jason.Encoder, except: [:__meta__, :__struct__, :timer, :inserted_at, :updated_at]} + schema "items" do + field :person_id, :integer + field :status, :integer + field :text, :string + + has_many :timer, Timer + many_to_many(:tags, Tag, join_through: ItemTag, on_replace: :delete) + + timestamps() + end +``` + +We are now encoding *all the fields* +except those that are specified. + +The user can now send a second parameter +(which is `false` by default) +detailing if we want to fetch the `item` +with `tags` preloaded. + +Now let's *use* this changed function +in the `:show` endpoint of `/items`. +In `lib/api/item.ex`, +change `show/2` to the following. + +```elixir + def show(conn, %{"id" => id} = params) do + + embed = Map.get(params, "embed", "") + + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + retrieve_tags = embed =~ "tag" + case Item.get_item(id, retrieve_tags) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + item -> + if retrieve_tags do + json(conn, item) + else + # Since tags is Ecto.Association.NotLoaded, we have to remove it. + # By removing it, the object is no longer an Item struct, hence why encoding fails (@derive no longer applies). + # We need to remove the rest of the unwanted fields from the "now-map" object. + # + # Jason.Encode should do this instead of removing here. + item = Map.drop(item, [:tags, :timer, :__meta__, :__struct__, :inserted_at, :updated_at]) + json(conn, item) + end + end + + # ID is not an integer + :error -> + errors = %{ + code: 400, + message: "Item \'id\' should be an integer." + } + + json(conn |> put_status(400), errors) + end + end +``` + +We've retrieved the `embed` query parameter +and check if it has a value of `"tag"`. +Depending on whether the person wants `tags` or not, +the result is returned normally. + +### 7.3.3 A note about `Jason` encoding + +There is another small change +we ought to make. +Since we changed the `@derive` annotation, +the `update/2` function, after updating, +retrieves the `item` and *tries* to encode +`tags`. + +If you run `mix test` +or try to update an item (`PUT /items/:id`), +you will see an error pop up in the terminal. + +```sh + ** (RuntimeError) cannot encode association :tags from App.Item to JSON because the association was not loaded. + + You can either preload the association: + + Repo.preload(App.Item, :tags) + + Or choose to not encode the association when converting the struct to JSON by explicitly listing the JSON fields in your schema: + + defmodule App.Item do + # ... + + @derive {Jason.Encoder, only: [:name, :title, ...]} + schema ... do + +``` + +`Jason.Encoder` expects `tags` to be loaded. +However, they are not *preloaded by default*. +The `item` after being updated and returned to the API controller +looks like so: + +```elixir +item #=> %App.Item{ + __meta__: #Ecto.Schema.Metadata<:loaded, "items">, + id: 1, + person_id: 42, + status: 0, + text: "some updated text", + timer: #Ecto.Association.NotLoaded, + tags: #Ecto.Association.NotLoaded, + inserted_at: ~N[2023-01-26 16:19:29], + updated_at: ~N[2023-01-26 16:19:30] +} +``` + +Notice how `:tags` nor `:timer` are not loaded. +`Jason.Encoder` doesn't know how to encode this in a `json` format. + +This is why we **also need to add the following line**... + +`Map.drop(item, [:tags, :timer, :__meta__, :__struct__, :inserted_at, :updated_at])` + +... to the `update/2` function, like so: + +```elixir + def update(conn, params) do + id = Map.get(params, "id") + new_text = Map.get(params, "text") + + # Get item with the ID + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } + + json(conn |> put_status(404), errors) + + # If item is found, try to update it + item -> + case Item.update_item(item, %{text: new_text}) do + # Successfully updates item + {:ok, %{model: item, version: _version}} -> + item = Map.drop(item, [:tags, :timer, :__meta__, :__struct__, :inserted_at, :updated_at]) + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + end +``` + +CHANGEHERE +e.g. +`lib/api/item` + +Because `tags` is not loaded, +we **remove it**. +But by removing this field, +the object *is no longer an `Item` struct*, +so `Jason` doesn't know how to serialize `__meta__` or `__struct__`. + +**This is why we remove the detailed fields whenever `tags` are not loaded.** + +After all of these changes, +if you run `mix test`, +all tests should run properly! + +Congratulations, +now the person using the API +can *choose* to retrieve an `item` with `tags` or not! + # 8. _Advanced/Automated_ `API` Testing Using `Hoppscotch` `API` testing is an essential part From bdd0530855b2178af1c79ee1f633f92f6f19f496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 26 Jan 2023 16:35:45 +0000 Subject: [PATCH 30/34] fix: Fixing API.md typos. --- api.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/api.md b/api.md index 0694fbc5..1986681b 100644 --- a/api.md +++ b/api.md @@ -2200,7 +2200,8 @@ to return tags according to a second boolean parameter. We will implement this change shortly. Let's add the test that will check the `:show` endpoint -and if tags are being returned when requested. +and if `tags` are being returned when requested. + Inside `test/api/item_test.exs`, add the following test. @@ -2216,7 +2217,7 @@ add the following test. ``` We are passing a `embed` query parameter with `tags` value -and returning an array of `tags`. +and expect a return with an array of `tags`. Now that we have the tests added, let's implement the needed changes for these to pass! @@ -2242,6 +2243,11 @@ Change it so it looks like the following. end ``` +The user can now send a second parameter +(which is `false` by default) +detailing if we want to fetch the `item` +with `tags` preloaded. + We are also going to change how to encode the schema. Change the `@derive` annotation to the following. @@ -2262,13 +2268,9 @@ Change the `@derive` annotation to the following. We are now encoding *all the fields* except those that are specified. -The user can now send a second parameter -(which is `false` by default) -detailing if we want to fetch the `item` -with `tags` preloaded. - Now let's *use* this changed function in the `:show` endpoint of `/items`. + In `lib/api/item.ex`, change `show/2` to the following. @@ -2415,9 +2417,8 @@ This is why we **also need to add the following line**... end ``` -CHANGEHERE e.g. -`lib/api/item` +[`lib/api/item`](https://github.com/dwyl/mvp/blob/a7a234479467e6c83bd837ca74a2f50433e992df/lib/api/item.ex) Because `tags` is not loaded, we **remove it**. From 79da854f341cfd0cb641084125b01ec941b30d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 26 Jan 2023 16:36:02 +0000 Subject: [PATCH 31/34] fix: Mix format. --- lib/api/item.ex | 24 +++++++++++++++++++++--- lib/app/item.ex | 10 ++++++---- test/api/item_test.exs | 6 +++++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/lib/api/item.ex b/lib/api/item.ex index 002be570..cc95c858 100644 --- a/lib/api/item.ex +++ b/lib/api/item.ex @@ -5,13 +5,13 @@ defmodule API.Item do import Ecto.Changeset def show(conn, %{"id" => id} = params) do - embed = Map.get(params, "embed", "") case Integer.parse(id) do # ID is an integer {id, _float} -> retrieve_tags = embed =~ "tag" + case Item.get_item(id, retrieve_tags) do nil -> errors = %{ @@ -30,7 +30,16 @@ defmodule API.Item do # We need to remove the rest of the unwanted fields from the "now-map" object. # # Jason.Encode should do this instead of removing here. - item = Map.drop(item, [:tags, :timer, :__meta__, :__struct__, :inserted_at, :updated_at]) + item = + Map.drop(item, [ + :tags, + :timer, + :__meta__, + :__struct__, + :inserted_at, + :updated_at + ]) + json(conn, item) end end @@ -133,7 +142,16 @@ defmodule API.Item do case Item.update_item(item, %{text: new_text}) do # Successfully updates item {:ok, %{model: item, version: _version}} -> - item = Map.drop(item, [:tags, :timer, :__meta__, :__struct__, :inserted_at, :updated_at]) + item = + Map.drop(item, [ + :tags, + :timer, + :__meta__, + :__struct__, + :inserted_at, + :updated_at + ]) + json(conn, item) # Error creating item diff --git a/lib/app/item.ex b/lib/app/item.ex index 5156b712..c0e5065e 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -7,7 +7,8 @@ defmodule App.Item do alias __MODULE__ require Logger - @derive {Jason.Encoder, except: [:__meta__, :__struct__, :timer, :inserted_at, :updated_at]} + @derive {Jason.Encoder, + except: [:__meta__, :__struct__, :timer, :inserted_at, :updated_at]} schema "items" do field :person_id, :integer field :status, :integer @@ -110,9 +111,10 @@ defmodule App.Item do iex> get_item(1313) nil """ - def get_item(id, preload_tags \\ false ) do - item = Item - |> Repo.get(id) + def get_item(id, preload_tags \\ false) do + item = + Item + |> Repo.get(id) if(preload_tags == true) do item |> Repo.preload(tags: from(t in Tag, order_by: t.text)) diff --git a/test/api/item_test.exs b/test/api/item_test.exs index fa5e4308..92c73d61 100644 --- a/test/api/item_test.exs +++ b/test/api/item_test.exs @@ -30,7 +30,11 @@ defmodule API.ItemTest do test "specific item with tags", %{conn: conn} do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) - conn = get(conn, Routes.api_item_path(conn, :show, item.id), %{"embed" => "tags"}) + + conn = + get(conn, Routes.api_item_path(conn, :show, item.id), %{ + "embed" => "tags" + }) assert json_response(conn, 200)["id"] == item.id assert json_response(conn, 200)["text"] == item.text From 0d81173d24b5710568f7085c35de2e55f9cc06e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 26 Jan 2023 17:20:30 +0000 Subject: [PATCH 32/34] feat: Updating CI tests with fetching items with tags. #256 --- lib/api/MVP.json | 821 +++++++++++++++++++++-------------------- lib/api/envs.json | 24 +- lib/api/localhost.json | 3 +- priv/repo/seeds.exs | 2 +- 4 files changed, 444 insertions(+), 406 deletions(-) diff --git a/lib/api/MVP.json b/lib/api/MVP.json index 945c544d..83f721ed 100644 --- a/lib/api/MVP.json +++ b/lib/api/MVP.json @@ -1,142 +1,150 @@ [ { - "requests": [], - "name": "MVP", - "v": 1, "folders": [ { - "name": "Items", "folders": [], + "name": "Items", "v": 1, "requests": [ { + "params": [], "endpoint": "<>/api/items/<>", - "method": "GET", - "auth": { - "value": "", - "authType": "none", - "authActive": true, - "key": "", - "addTo": "Headers" - }, "headers": [ { - "value": "application/json", "active": true, - "key": "accept" + "key": "accept", + "value": "application/json" } ], + "preRequestScript": "", "body": { - "contentType": null, - "body": null + "body": null, + "contentType": null }, "v": "1", "testScript": "// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});", "name": "Get item", - "preRequestScript": "", - "params": [] - }, - { - "body": { - "contentType": null, - "body": null - }, "auth": { "authActive": true, + "value": "", + "addTo": "Headers", + "key": "", "authType": "none" }, + "method": "GET" + }, + { "headers": [ { + "value": "application/json", "key": "accept", - "active": true, - "value": "application/json" + "active": true } ], - "endpoint": "<>/api/items/<>", - "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "method": "GET", + "preRequestScript": "", + "auth": { + "authType": "none", + "authActive": true + }, "params": [], + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", "name": "Get item (404 - Item not found)", - "method": "GET", + "endpoint": "<>/api/items/<>", "v": "1", - "preRequestScript": "" + "body": { + "contentType": null, + "body": null + } }, { "body": { "body": null, "contentType": null }, + "params": [], + "preRequestScript": "", + "method": "GET", + "name": "Get item (400 - Invalid ID)", + "endpoint": "<>/api/items/<>", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", "headers": [ { - "active": true, "value": "application/json", - "key": "accept" + "key": "accept", + "active": true } ], - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", "v": "1", - "params": [], - "method": "GET", "auth": { - "authActive": true, - "authType": "none" - }, - "endpoint": "<>/api/items/<>", - "preRequestScript": "", - "name": "Get item (400 - Invalid ID)" + "authType": "none", + "authActive": true + } }, { "v": "1", - "endpoint": "<>/api/items", - "name": "Create item", - "params": [], + "endpoint": "<>/api/items/<>", + "name": "Get item with tag embed", + "params": [ + { + "key": "embed", + "value": "tag", + "active": true + } + ], "headers": [ { + "value": "application/json", "active": true, - "key": "accept", - "value": "application/json" + "key": "accept" } ], - "method": "POST", + "method": "GET", "auth": { + "value": "", + "authType": "none", "authActive": true, - "authType": "none" + "key": "", + "addTo": "Headers" }, "preRequestScript": "", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "testScript": "// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n pw.expect(pw.response.body.tags).not.toBeType(\"undefined\");\n});", "body": { - "contentType": "application/json", - "body": "{\n \"text\": \"aaa text\"\n}" + "contentType": null, + "body": null } }, { - "preRequestScript": "", - "body": { - "body": "{\n \"invalid\": \"something\"\n}", - "contentType": "application/json" - }, - "method": "POST", - "params": [], "headers": [ { "key": "accept", - "active": true, - "value": "application/json" + "value": "application/json", + "active": true } ], + "method": "POST", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"aaa text\"\n}" + }, "v": "1", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", "endpoint": "<>/api/items", + "params": [], + "preRequestScript": "", "auth": { "authType": "none", "authActive": true }, - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "name": "Create item (400 - Invalid attributes)" + "name": "Create item" }, { - "v": "1", + "body": { + "contentType": "application/json", + "body": "{\n \"invalid\": \"something\"\n}" + }, + "name": "Create item (400 - Invalid attributes)", "endpoint": "<>/api/items", - "name": "Create item with tags", - "params": [], "headers": [ { "active": true, @@ -145,227 +153,272 @@ } ], "method": "POST", + "preRequestScript": "", "auth": { "authActive": true, "authType": "none" }, + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "params": [], + "v": "1" + }, + { + "v": "1", "preRequestScript": "", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "headers": [ + { + "value": "application/json", + "key": "accept", + "active": true + } + ], + "endpoint": "<>/api/items", "body": { "contentType": "application/json", "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"text\": \"yet another tag\",\n \"color\": \"#FFFFFF\"\n }\n ]\n}" + }, + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "method": "POST", + "name": "Create item with tags", + "params": [], + "auth": { + "authActive": true, + "authType": "none" } }, { - "v": "1", - "endpoint": "<>/api/items", "name": "Create item with tags (400 - Tag already exists)", - "params": [], "headers": [ { - "active": true, + "value": "application/json", "key": "accept", - "value": "application/json" + "active": true } ], + "endpoint": "<>/api/items", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "params": [], "method": "POST", "auth": { - "authActive": true, - "authType": "none" + "authType": "none", + "authActive": true }, "preRequestScript": "", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "v": "1", "body": { - "contentType": "application/json", - "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"text\": \"yet another tag\",\n \"color\": \"#FFFFFF\"\n }\n ]\n}" + "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"text\": \"yet another tag\",\n \"color\": \"#FFFFFF\"\n }\n ]\n}", + "contentType": "application/json" } }, { - "v": "1", "endpoint": "<>/api/items", "name": "Create item with tags (400 - Tag is malformed)", - "params": [], + "auth": { + "authType": "none", + "authActive": true + }, "headers": [ { - "active": true, + "value": "application/json", "key": "accept", - "value": "application/json" + "active": true } ], - "method": "POST", - "auth": { - "authActive": true, - "authType": "none" - }, - "preRequestScript": "", "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "params": [], + "method": "POST", + "v": "1", "body": { "contentType": "application/json", "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"text\": \"yet another tag\",\n \"color\": \"#FFFFFF\"\n }\n ]\n}" - } + }, + "preRequestScript": "" }, { - "v": "1", - "endpoint": "<>/api/items", "name": "Create item with tags (400 - Tag is malformed)", + "preRequestScript": "", + "method": "POST", "params": [], + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", "headers": [ { - "active": true, + "value": "application/json", "key": "accept", - "value": "application/json" + "active": true } ], - "method": "POST", - "auth": { - "authActive": true, - "authType": "none" - }, - "preRequestScript": "", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", "body": { "contentType": "application/json", "body": "{\n \"text\": \"some text\",\n \"tags\": [\n {\n \"invalid\": \"yet another tag\"\n }\n ]\n}" + }, + "endpoint": "<>/api/items", + "v": "1", + "auth": { + "authType": "none", + "authActive": true } }, { - "endpoint": "<>/api/items/<>", - "name": "Update item", - "v": "1", - "body": { - "body": "{\n \"text\": \"new updated text\"\n}", - "contentType": "application/json" + "params": [], + "auth": { + "authType": "none", + "authActive": true }, + "name": "Update item", + "endpoint": "<>/api/items/<>", "headers": [ { - "key": "accept", "value": "application/json", - "active": true + "active": true, + "key": "accept" } ], - "auth": { - "authType": "none", - "authActive": true + "body": { + "body": "{\n \"text\": \"new updated text\"\n}", + "contentType": "application/json" }, "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.status).toBeType(\"number\");\n});", - "params": [], - "preRequestScript": "", - "method": "PUT" + "method": "PUT", + "v": "1", + "preRequestScript": "" }, { + "method": "PUT", "headers": [ { - "key": "accept", "active": true, + "key": "accept", "value": "application/json" } ], - "method": "PUT", - "name": "Update item (404 - Item not found)", - "endpoint": "<>/api/items/<>", - "preRequestScript": "", - "params": [], "auth": { "authActive": true, "authType": "none" }, "v": "1", "body": { - "contentType": "application/json", - "body": "{\n \"text\": \"new updated text\"\n}" + "body": "{\n \"text\": \"new updated text\"\n}", + "contentType": "application/json" }, - "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});" + "preRequestScript": "", + "params": [], + "name": "Update item (404 - Item not found)", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "endpoint": "<>/api/items/<>" } ] }, { - "name": "Timers", - "v": 1, - "folders": [], "requests": [ { - "headers": [ - { + "endpoint": "<>/api/items/<>/timers", + "v": "1", + "params": [], + "name": "Get timers", + "preRequestScript": "", + "method": "GET", + "headers": [ + { "key": "accept", "active": true, "value": "application/json" } ], "body": { - "body": null, - "contentType": null + "contentType": null, + "body": null }, - "endpoint": "<>/api/items/<>/timers", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", "auth": { - "authType": "none", - "authActive": true - }, - "method": "GET", - "name": "Get timers", - "params": [], - "v": "1", - "preRequestScript": "", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});" + "authActive": true, + "authType": "none" + } }, { "preRequestScript": "", - "method": "GET", - "body": { - "contentType": null, - "body": null - }, "auth": { "authActive": true, "authType": "none" }, - "endpoint": "<>/api/items/<>/timers/<>", - "params": [], - "name": "Get timer", "headers": [ { + "value": "application/json", "key": "accept", - "active": true, - "value": "application/json" + "active": true } ], + "params": [], + "v": "1", + "body": { + "body": null, + "contentType": null + }, + "name": "Get timer", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", - "v": "1" + "endpoint": "<>/api/items/<>/timers/<>", + "method": "GET" }, { + "headers": [ + { + "active": true, + "value": "application/json", + "key": "accept" + } + ], + "name": "Get timer (404 - Timer not found)", + "preRequestScript": "", "auth": { "authType": "none", "authActive": true }, - "params": [], - "name": "Get timer (404 - Timer not found)", + "v": "1", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "body": { + "body": null, + "contentType": null + }, + "method": "GET", + "endpoint": "<>/api/items/<>/timers/<>", + "params": [] + }, + { + "preRequestScript": "", "body": { "contentType": null, "body": null }, + "endpoint": "<>/api/items/<>/timers/<>", + "method": "GET", + "params": [], + "name": "Get timer (400 - Invalid ID)", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "v": "1", + "auth": { + "authActive": true, + "authType": "none" + }, "headers": [ { "key": "accept", "active": true, "value": "application/json" } - ], - "endpoint": "<>/api/items/<>/timers/<>", - "v": "1", - "method": "GET", - "preRequestScript": "", - "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});" + ] }, { "auth": { - "authType": "none", - "authActive": true + "authActive": true, + "authType": "none" }, + "method": "POST", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", "body": { - "body": null, - "contentType": null + "contentType": "application/json", + "body": "{\n \"start\": \"2023-01-11T17:40:44\"\n}" }, - "params": [], - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "name": "Create timer (custom start)", + "preRequestScript": "", "headers": [ { "key": "accept", @@ -373,18 +426,13 @@ "active": true } ], - "name": "Get timer (400 - Invalid ID)", - "endpoint": "<>/api/items/<>/timers/<>", - "method": "GET", - "preRequestScript": "", + "endpoint": "<>/api/items/<>/timers", + "params": [], "v": "1" }, { + "params": [], "v": "1", - "body": { - "body": "{\n \"start\": \"2023-01-11T17:40:44\"\n}", - "contentType": "application/json" - }, "headers": [ { "active": true, @@ -392,99 +440,74 @@ "value": "application/json" } ], - "params": [], - "method": "POST", - "name": "Create timer (custom start)", - "preRequestScript": "", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", - "endpoint": "<>/api/items/<>/timers", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", "auth": { "authType": "none", "authActive": true - } - }, - { - "v": "1", - "headers": [ - { - "value": "application/json", - "key": "accept", - "active": true - } - ], + }, + "name": "Create timer (no start)", "preRequestScript": "", + "endpoint": "<>/api/items/<>/timers", "body": { "contentType": null, "body": null }, - "auth": { - "authActive": true, - "authType": "none" - }, - "method": "POST", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", - "name": "Create timer (no start)", - "params": [], - "endpoint": "<>/api/items/<>/timers" + "method": "POST" }, { - "method": "POST", + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}" + }, "preRequestScript": "", "auth": { "authType": "none", "authActive": true }, - "params": [], "v": "1", - "name": "Create timer (400 - Stop is after start) ", + "params": [], + "method": "POST", + "endpoint": "<>/api/items/<>/timers", "headers": [ { - "value": "application/json", + "key": "accept", "active": true, - "key": "accept" + "value": "application/json" } ], - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "endpoint": "<>/api/items/<>/timers", - "body": { - "contentType": "application/json", - "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}" - } + "name": "Create timer (400 - Stop is after start) ", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});" }, { - "method": "POST", - "v": "1", "params": [], + "body": { + "contentType": "application/json", + "body": "{\n \"start\": \"2023-invalid-01\"\n}" + }, "auth": { "authType": "none", "authActive": true }, + "endpoint": "<>/api/items/<>/timers", + "name": "Create timer (400 - Invalid date format) ", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "method": "POST", "headers": [ { - "value": "application/json", "key": "accept", - "active": true + "active": true, + "value": "application/json" } ], - "endpoint": "<>/api/items/<>/timers", "preRequestScript": "", - "body": { - "body": "{\n \"start\": \"2023-invalid-01\"\n}", - "contentType": "application/json" - }, - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "name": "Create timer (400 - Invalid date format) " + "v": "1" }, { - "preRequestScript": "", - "auth": { - "authActive": true, - "authType": "none" - }, - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});", - "name": "Update timer", - "v": "1", "endpoint": "<>/api/items/<>/timers/<>", + "body": { + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}", + "contentType": "application/json" + }, "headers": [ { "value": "application/json", @@ -492,48 +515,49 @@ "key": "accept" } ], - "method": "PUT", + "name": "Update timer", "params": [], - "body": { - "contentType": "application/json", - "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:48\"\n}" - } + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.start).toBeType(\"string\");\n pw.expect(pw.response.body.stop).toBeType(\"string\");\n});", + "v": "1", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", + "method": "PUT" }, { + "v": "1", + "endpoint": "<>/api/items/<>/timers/<>", "params": [], "body": { - "contentType": "application/json", - "body": "{\n \"start\": \"2023-invalid-01\"\n}" + "body": "{\n \"start\": \"2023-invalid-01\"\n}", + "contentType": "application/json" }, - "endpoint": "<>/api/items/<>/timers/<>", - "v": "1", - "method": "PUT", + "auth": { + "authActive": true, + "authType": "none" + }, + "preRequestScript": "", "name": "Update timer (400 - Invalid date format)", "headers": [ { - "key": "accept", "active": true, + "key": "accept", "value": "application/json" } ], - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "preRequestScript": "", - "auth": { - "authActive": true, - "authType": "none" - } + "method": "PUT", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});" }, { - "method": "PUT", - "auth": { - "authActive": true, - "authType": "none" - }, "endpoint": "<>/api/items/<>/timers/<>", - "params": [], - "preRequestScript": "", - "v": "1", "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "v": "1", + "body": { + "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}", + "contentType": "application/json" + }, "headers": [ { "key": "accept", @@ -541,22 +565,27 @@ "active": true } ], - "body": { - "contentType": "application/json", - "body": "{\n \"start\": \"2023-01-11T17:40:44\",\n \"stop\": \"2023-01-11T17:40:40\"\n}" + "params": [], + "auth": { + "authType": "none", + "authActive": true }, - "name": "Update timer (400 - Stop is after start)" + "name": "Update timer (400 - Stop is after start)", + "method": "PUT", + "preRequestScript": "" }, { + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", "body": { - "contentType": null, - "body": null + "body": null, + "contentType": null }, - "name": "Stop timer", - "params": [], - "v": "1", + "preRequestScript": "", "endpoint": "<>/api/timers/<>", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});", + "v": "1", + "method": "PUT", + "params": [], + "name": "Stop timer", "headers": [ { "active": true, @@ -564,73 +593,82 @@ "value": "application/json" } ], - "method": "PUT", - "preRequestScript": "", "auth": { - "authType": "none", - "authActive": true + "authActive": true, + "authType": "none" } }, { - "method": "PUT", "name": "Stop timer (403 - Timer has already been stopped)", + "params": [], + "endpoint": "<>/api/timers/<>", + "preRequestScript": "", + "method": "PUT", "body": { - "body": null, - "contentType": null + "contentType": null, + "body": null }, - "endpoint": "<>/api/timers/<>", - "params": [], - "testScript": "\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});", "headers": [ { - "key": "accept", "active": true, + "key": "accept", "value": "application/json" } ], + "testScript": "\n\n// Check status code is 403\npw.test(\"Status code is 403\", ()=> {\n pw.expect(pw.response.status).toBe(403);\n});", "v": "1", "auth": { "authType": "none", "authActive": true - }, - "preRequestScript": "" + } }, { "method": "PUT", - "params": [], + "v": "1", + "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", "name": "Stop timer (404 - Timer not found)", - "auth": { - "authType": "none", - "authActive": true + "body": { + "contentType": null, + "body": null }, - "preRequestScript": "", "headers": [ { + "key": "accept", "value": "application/json", - "active": true, - "key": "accept" + "active": true } ], - "v": "1", "endpoint": "<>/api/timers/<>", - "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", - "body": { - "contentType": null, - "body": null + "params": [], + "preRequestScript": "", + "auth": { + "authActive": true, + "authType": "none" } } - ] + ], + "folders": [], + "v": 1, + "name": "Timers" }, { "v": 1, - "name": "Tags", "folders": [], "requests": [ { - "v": "1", + "testScript": "// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n \n});", + "auth": { + "addTo": "Headers", + "value": "", + "authActive": true, + "key": "", + "authType": "none" + }, + "params": [], "endpoint": "<>/api/tags/<>", "name": "Get tag", - "params": [], + "v": "1", + "method": "GET", "headers": [ { "value": "application/json", @@ -638,55 +676,47 @@ "key": "accept" } ], - "method": "GET", - "auth": { - "value": "", - "authType": "none", - "authActive": true, - "key": "", - "addTo": "Headers" - }, - "preRequestScript": "", - "testScript": "// Check status code is 2xx\npw.test(\"Status code is 2xx\", ()=> {\n pw.expect(pw.response.status).toBeLevel2xx();\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n \n});", "body": { - "contentType": null, - "body": null - } + "body": null, + "contentType": null + }, + "preRequestScript": "" }, { - "v": "1", + "body": { + "body": null, + "contentType": null + }, "endpoint": "<>/api/tags/<>", - "name": "Get tag (404 - Tag not found)", - "params": [], + "auth": { + "authType": "none", + "authActive": true + }, "headers": [ { + "value": "application/json", "key": "accept", - "active": true, - "value": "application/json" + "active": true } ], "method": "GET", - "auth": { - "authActive": true, - "authType": "none" - }, - "preRequestScript": "", "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", - "body": { - "contentType": null, - "body": null - } + "preRequestScript": "", + "name": "Get tag (404 - Tag not found)", + "params": [], + "v": "1" }, { + "body": { + "body": null, + "contentType": null + }, "v": "1", - "endpoint": "<>/api/tags/<>", - "name": "Get tag (400 - Invalid ID)", - "params": [], "headers": [ { - "active": true, + "key": "accept", "value": "application/json", - "key": "accept" + "active": true } ], "method": "GET", @@ -695,17 +725,17 @@ "authType": "none" }, "preRequestScript": "", + "params": [], + "endpoint": "<>/api/tags/<>", "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", - "body": { - "body": null, - "contentType": null - } + "name": "Get tag (400 - Invalid ID)" }, { - "v": "1", "endpoint": "<>/api/tags", - "name": "Create tag", - "params": [], + "auth": { + "authType": "none", + "authActive": true + }, "headers": [ { "active": true, @@ -713,71 +743,43 @@ "value": "application/json" } ], - "method": "POST", - "auth": { - "authActive": true, - "authType": "none" - }, - "preRequestScript": "", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", "body": { "contentType": "application/json", "body": "{\n \"text\": \"tag text\",\n \"person_id\": 0,\n \"color\": \"#FFFFFF\"\n}" - } - }, - { - "v": "1", - "endpoint": "<>/api/tags", - "name": "Create tag (no color provided)", - "params": [], - "headers": [ - { - "active": true, - "key": "accept", - "value": "application/json" - } - ], - "method": "POST", - "auth": { - "authActive": true, - "authType": "none" }, "preRequestScript": "", - "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", - "body": { - "contentType": "application/json", - "body": "{\n \"text\": \"another tag text\",\n \"person_id\": 0\n}" - } + "v": "1", + "params": [], + "name": "Create tag", + "method": "POST" }, { - "v": "1", "endpoint": "<>/api/tags", - "name": "Create tag (400 - Invalid attributes)", - "params": [], "headers": [ { - "key": "accept", + "value": "application/json", "active": true, - "value": "application/json" + "key": "accept" } ], + "v": "1", "method": "POST", "auth": { "authType": "none", "authActive": true }, + "params": [], + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.id).toBeType(\"number\");\n});", + "name": "Create tag (no color provided)", "preRequestScript": "", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", "body": { - "body": "{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}", - "contentType": "application/json" + "contentType": "application/json", + "body": "{\n \"text\": \"another tag text\",\n \"person_id\": 0\n}" } }, { - "v": "1", - "endpoint": "<>/api/tags/<>", - "name": "Update tag", - "params": [], + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", "headers": [ { "key": "accept", @@ -785,47 +787,75 @@ "active": true } ], - "method": "PUT", + "body": { + "contentType": "application/json", + "body": "{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}" + }, + "params": [], "auth": { "authType": "none", "authActive": true }, "preRequestScript": "", + "name": "Create tag (400 - Invalid attributes)", + "endpoint": "<>/api/tags", + "method": "POST", + "v": "1" + }, + { "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.person_id).toBeType(\"number\");\n pw.expect(pw.response.body.text).toBeType(\"string\");\n\n \t\n \t// Color must be an hex color\n pw.expect(pw.response.body.color).toBeType(\"string\");\n pw.expect(pw.response.body.color).toHaveLength(7);\n pw.expect(pw.response.body.color).toInclude(\"#\");\n});", + "name": "Update tag", "body": { "body": "{\n \"text\": \"new updated tag text\"\n}", "contentType": "application/json" - } - }, - { + }, "v": "1", - "endpoint": "<>/api/tags/<>", - "name": "Update tag (404 - Tag not found)", - "params": [], "headers": [ { + "value": "application/json", "key": "accept", - "active": true, - "value": "application/json" + "active": true } ], - "method": "PUT", "auth": { "authActive": true, "authType": "none" }, + "endpoint": "<>/api/tags/<>", + "method": "PUT", + "params": [], + "preRequestScript": "" + }, + { "preRequestScript": "", "testScript": "// Check status code is 404\npw.test(\"Status code is 404\", ()=> {\n pw.expect(pw.response.status).toBe(404);\n});", + "name": "Update tag (404 - Tag not found)", + "headers": [ + { + "value": "application/json", + "active": true, + "key": "accept" + } + ], "body": { "contentType": "application/json", "body": "{\n \"text\": \"new updated text\"\n}" - } + }, + "auth": { + "authType": "none", + "authActive": true + }, + "params": [], + "method": "PUT", + "endpoint": "<>/api/tags/<>", + "v": "1" }, { - "v": "1", - "endpoint": "<>/api/tags/<>", "name": "Update tag (400 - Invalid attributes)", - "params": [], + "endpoint": "<>/api/tags/<>", + "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", + "preRequestScript": "", + "v": "1", "headers": [ { "key": "accept", @@ -834,19 +864,22 @@ } ], "method": "PUT", - "auth": { - "authActive": true, - "authType": "none" - }, - "preRequestScript": "", - "testScript": "// Check status code is 400\npw.test(\"Status code is 400\", ()=> {\n pw.expect(pw.response.status).toBe(400);\n});", "body": { "contentType": "application/json", "body": "{\n \"text\": \"tag text\",\n \"color\": \"invalid color\"\n}" - } + }, + "auth": { + "authType": "none", + "authActive": true + }, + "params": [] } - ] + ], + "name": "Tags" } - ] + ], + "name": "MVP", + "v": 1, + "requests": [] } ] \ No newline at end of file diff --git a/lib/api/envs.json b/lib/api/envs.json index 91df5403..d77ddc66 100644 --- a/lib/api/envs.json +++ b/lib/api/envs.json @@ -3,8 +3,8 @@ "name": "Localhost", "variables": [ { - "key": "host", - "value": "http://localhost:4000" + "value": "http://localhost:4000", + "key": "host" }, { "value": "1", @@ -15,28 +15,32 @@ "key": "notfound_item_id" }, { - "value": "invalid_id", - "key": "invalid_id" + "key": "invalid_id", + "value": "invalid_id" }, { - "value": "1", - "key": "timer_id" + "key": "timer_id", + "value": "1" }, { "value": "-1", "key": "notfound_timer_id" }, { - "value": "2", - "key": "timer_id_to_stop" + "key": "timer_id_to_stop", + "value": "2" }, { "key": "tag_id", "value": "1" }, { - "key": "notfound_tag_id", - "value": "-1" + "value": "-1", + "key": "notfound_tag_id" + }, + { + "value": "1", + "key": "item_id_with_tag" } ] } diff --git a/lib/api/localhost.json b/lib/api/localhost.json index b48f28cd..644c356d 100644 --- a/lib/api/localhost.json +++ b/lib/api/localhost.json @@ -7,5 +7,6 @@ "notfound_timer_id": "-1", "timer_id_to_stop": 2, "tag_id": 1, - "notfound_tag_id": -1 + "notfound_tag_id": -1, + "item_id_with_tag": 1 } \ No newline at end of file diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 2d91e4a7..5d617bd3 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -11,7 +11,7 @@ end if Mix.env() == :dev do # Create item - App.Item.create_item(%{text: "random text", person_id: 0, status: 2}) + App.Item.create_item_with_tags(%{text: "random text", person_id: 0, status: 2, tags: [%{text: "first_tag", person_id: 0}]}) # Create timers {:ok, _timer} = From be24c8b17118400524189ff0600a1fb4cebb38b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 26 Jan 2023 17:22:30 +0000 Subject: [PATCH 33/34] fix: Mix format. --- priv/repo/seeds.exs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 5d617bd3..09796df7 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -11,7 +11,12 @@ end if Mix.env() == :dev do # Create item - App.Item.create_item_with_tags(%{text: "random text", person_id: 0, status: 2, tags: [%{text: "first_tag", person_id: 0}]}) + App.Item.create_item_with_tags(%{ + text: "random text", + person_id: 0, + status: 2, + tags: [%{text: "first_tag", person_id: 0}] + }) # Create timers {:ok, _timer} = From e295ced6663e083a8a858651bbd692ce73fc7282 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Sat, 28 Jan 2023 10:54:00 +0000 Subject: [PATCH 34/34] hoppscotch testing section moved to lib/api/hoppscotch.md https://github.com/dwyl/mvp/pull/280/files#r1089467337 --- api.md | 395 +----------------------------------------- lib/api/hoppscotch.md | 392 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 397 insertions(+), 390 deletions(-) create mode 100644 lib/api/hoppscotch.md diff --git a/api.md b/api.md index 1986681b..a5bb35a7 100644 --- a/api.md +++ b/api.md @@ -17,6 +17,11 @@ can also be done through our `REST API` *and* `WebSocket API` (for all real-time updates). +The API is tested using `Hoppscotch`. +For the setup guide, +see: +[`lib/api/hoppscotch.md`](https://github.com/dwyl/mvp/blob/main/lib/api/hoppscotch.md) +
@@ -51,12 +56,6 @@ can also be done through our `REST API` - [7.3.1 Adding tests](#731-adding-tests-1) - [7.3.2 Returning `item` with `tags` on endpoint](#732-returning-item-with-tags-on-endpoint) - [7.3.3 A note about `Jason` encoding](#733-a-note-about-jason-encoding) -- [8. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#8-advancedautomated-api-testing-using-hoppscotch) - - [8.0 `Hoppscotch` Setup](#80-hoppscotch-setup) - - [8.1 Using `Hoppscotch`](#81-using-hoppscotch) - - [8.2 Integration with `Github Actions` with `Hoppscotch CLI`](#82-integration-with-github-actions-with-hoppscotch-cli) - - [8.2.1 Changing the workflow `.yml` file](#821-changing-the-workflow-yml-file) - - [8.2.2 Changing the `priv/repo/seeds.exs` file](#822-changing-the-privreposeedsexs-file) - [Done! ✅](#done-) @@ -2436,391 +2435,7 @@ Congratulations, now the person using the API can *choose* to retrieve an `item` with `tags` or not! -# 8. _Advanced/Automated_ `API` Testing Using `Hoppscotch` - -`API` testing is an essential part -of the development lifecycle. -Incorporating tests will allow us -to avoid regressions -and make sure our `API` performs -the way it's supposed to. -In other words, -the `person` using the API -*expects* consistent responses to their requests. - -Integrating this into a -[CI pipeline](https://en.wikipedia.org/wiki/Continuous_integration) -automates this process -and helps avoiding unintentional breaking changes. - -We are going to be using -[`Hoppscotch`](https://github.com/hoppscotch/hoppscotch). -This is an open source tool -similar to [`Postman`](https://www.postman.com/) -that allow us to make requests, -organize them and create test suites. - -Red more about `Hoppscotch`: -[hoppscotch.io](https://hoppscotch.io) - -## 8.0 `Hoppscotch` Setup - -There is no `App` to download, -but you can run `Hoppscotch` as -an "installable" [`PWA`](https://web.dev/what-are-pwas/): -![hoppscotch-docs-pwa](https://user-images.githubusercontent.com/194400/213877931-47344cfd-4dd7-491e-b032-9e65dff49ebc.png) - -In `Google Chrome` and `Microsoft Edge` -you will see an icon -in the Address bar to -"Install Hoppscotch app": - -image - -That will create what _looks_ like a "Native" App on your `Mac`: - -image - -Which then opens full-screen an _feels_ `Native`: - -Hoppscotch PWA Homescreen - -And you're all set to start testing the `API`. - -> Installing the `PWA` will _significantly_ increase your dev speed -because you can easily ``+`Tab` between your IDE and `Hoppscotch` -and not have to hunt for a Tab in your Web Browser. - -You can use `Hoppscotch` anonymously -(without logging in), -without any loss of functionality. - -If you decide to Authenticate -and you don't want to see the noise in the Top Nav, -simply enable "Zen Mode": - -![hoppscotch-zen-mode](https://user-images.githubusercontent.com/194400/213877013-0ff9c65d-10dc-4741-aa67-395e9fd6adb7.gif) - -With that out of the way, let's get started _using_ `Hoppscotch`! - - -## 8.1 Using `Hoppscotch` - -When you first open `Hoppscotch`, -either in the browser or as a `PWA`, -you will not have anything defined: - -![hoppscotch-empty](https://user-images.githubusercontent.com/194400/213889044-0e38256d-0c59-41f0-bbbe-ee54a16583e2.png) - - -The _first_ thing to do is open an _existing_ collection: - -hoppscotch-open-collection - -Import from hoppscotch: `/lib/api/MVP.json` - -hoppscotch-open-local - -Collection imported: - -image - -_Next_ you'll need to open environment configuration / variables: - -hoppscotch-open-environment - - -![hoppscotch-open-env](https://user-images.githubusercontent.com/194400/213889224-45dd660e-874d-422c-913d-bfdba1052944.png) - -When you click on `Localhost`, you will see an `Edit Environment` Modal: - -image - -**environment variables** -let us switch -between development or production environments seamlessly. - -Even after you have imported the environment configuration file, -it's not automatically selected: - -hoppscotch-environment-not-found - -You need to **_manually_ select `Localhost`**. -With the "Environments" tab selected, click the "Select environment" selector and chose "Localhost": - -hoppscotch-select-environment-localhost - -Once you've selected the `Localhost` environment, the `<>` placeholder will turn from red to blue: - -image - -After importing the collection, -open the `MVP` and `Items` folder, -you will see a list of possible requests. - - -After importing the collection and environment, it _still_ won't work ... -image - -You will see the message: - -**Could not send request**. -Unable to reach the API endpoint. Check your network
connection or select a different interceptor and try again. - - -These are the available options: - -![image](https://user-images.githubusercontent.com/194400/213896782-b96d97a5-5e42-41ec-b299-e64c77246b79.png) - -If you select "Browser extension" it will open the Chrome web store where you can install the extension. - -Install the extension. -Once installed, -add the the `http://localhost:4000` origin: - -add endpoint - -Then the presence of the extension will be visible in the Hoppscotch window/UI: - -![image](https://user-images.githubusercontent.com/194400/213896932-a8f48f2a-f5ee-47c1-aad6-d9a09cf27b48.png) - -image - - -Now you can start testing the requests! -Start the Phoenix server locally -by running `mix s` - -The requests in the collection will _finally_ work: - -![image](https://user-images.githubusercontent.com/194400/213897127-c70a5961-1db6-4d1f-a944-cf08a5bf2f86.png) - - - -If you open `MVP, Items` -and try to `Get Item` (by clicking `Send`), -you will receive a response from the `localhost` server. - -get1 -get2 -get3 - -Depending if the `item` with `id=1` -(which is defined in the *env variable* `item_id` -in the `localhost` environment), -you will receive a successful response -or an error, detailing the error -that the item was not found with the given `id`. - -You can create **tests** for each request, -asserting the response object and HTTP code. -You can do so by clicking the `Tests` tab. - -test - -These tests are important to validate -the expected response of the API. -For further information -on how you can test the response in each request, -please visit their documentation at -https://docsapi.io/features/tests. - -## 8.2 Integration with `Github Actions` with `Hoppscotch CLI` - -These tests can (and should!) -be used in CI pipelines. -To integrate this in our Github Action, -we will need to make some changes to our -[workflow file](https://docs.github.com/en/actions/using-workflows) -in `.github/worflows/ci.yml`. - -We want the [runner](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions#runners) -to be able to *execute* these tests. - -For this, we are going to be using -[**`Hoppscotch CLI`**](https://docs.hoppscotch.io/cli). - -With `hopp` (Hoppscotch CLI), -we will be able to run the collection of requests -and its tests in a command-line environment. - -To run the tests inside a command-line interface, -we are going to need two files: -- **environment file**, -a `json` file with each env variable as key -and its referring value. -For an example, -check the -[`lib/api/localhost.json` file](./lib/api/localhost.json). -- **collection file**, -the `json` file with all the requests. -It is the one you imported earlier. -You can export it the same way you imported it. -For an example, -check the -[`/lib/api/MVP.json` file](./lib/api/MVP.json). - -These files -will need to be pushed into the git repo. -The CI will need access to these files -to run `hopp` commands. - -In the case of our application, -for the tests to run properly, -we need some bootstrap data -so each request runs successfully. -For this, -we also added a -[`api_test_mock_data.sql`](lib/api/api_test_mock_data.sql) -`SQL` script file that will insert some mock data. - -### 8.2.1 Changing the workflow `.yml` file - -It's time to add this API testing step -into our CI workflow! -For this, open `.github/workflows/ci.yml` -and add the following snippet of code -between the `build` and `deploy` jobs. - - -```yml - # API Definition testing - # https://docs.hoppscotch.io/cli - api_definition: - name: API Definition Tests - runs-on: ubuntu-latest - services: - postgres: - image: postgres:12 - ports: ['5432:5432'] - env: - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - strategy: - matrix: - otp: ['25.1.2'] - elixir: ['1.14.2'] - steps: - - uses: actions/checkout@v2 - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - otp-version: ${{ matrix.otp }} - elixir-version: ${{ matrix.elixir }} - - name: Restore deps and _build cache - uses: actions/cache@v3 - with: - path: | - deps - _build - key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} - restore-keys: | - deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} - - name: Install dependencies - run: mix deps.get - - - name: Install Hoppscotch CLI - run: npm i -g @hoppscotch/cli - - # Setups database and adds seed data for API definition tests - - name: Run mix setup - run: mix ecto.setup - env: - MIX_ENV: dev - AUTH_API_KEY: ${{ secrets.AUTH_API_KEY }} - - - name: Running server and Hoppscotch Tests - run: mix phx.server & sleep 5 && hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json -``` - -Let's breakdown what we just added. -We are running this job in a -[service container](https://docs.github.com/en/actions/using-containerized-services/about-service-containers) -that includes a PostgreSQL database - -similarly to the existent `build` job. - -We then install the `Hoppscotch CLI` -by running `npm i -g @hoppscotch/cli`. - -We then run `mix ecto.setup`. -This command creates the database, -runs the migrations -and executes `run priv/repo/seeds.exs`. -The list of commands is present -in the [`mix.exs` file](./mix.exs). - -We are going to change the `seeds.exs` -file to bootstrap the database -with sample data for the API tests to run. - - -At last, -we run the API by running `mix phx.server` -and execute `hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json`. -This `hopp` command takes the environment file -and the collections file -and executes its tests. -You might notice we are using `sleep 5`. -This is because we want the `hopp` -command to be executed -after `mix phx.server` finishes initializing. - -And you should be done! -When running `hopp test`, -you will see the result of each request test. - -```sh -↳ API.Item.update/2, at: lib/api/item.ex:65 - 400 : Bad Request (0.049 s) -[info] Sent 400 in 4ms - ✔ Status code is 400 - Ran tests in 0.001 s - -Test Cases: 0 failed 31 passed -Test Suites: 0 failed 28 passed -Test Scripts: 0 failed 22 passed -Tests Duration: 0.041 s -``` - -If one test fails, the whole build fails, as well! - -### 8.2.2 Changing the `priv/repo/seeds.exs` file - -As we mentioned prior, -the last thing we need to do is -to change our `priv/repo/seeds.exs` file -so it adds sample data for the tests to run -when calling `mix ecto.setup`. -Use the following piece of code -and change `seeds.exs` to look as such. - - -```elixir -if not Envar.is_set?("AUTH_API_KEY") do - Envar.load(".env") -end - -if Mix.env() == :dev do - App.Item.create_item(%{text: "random text", person_id: 0, status: 2}) - - {:ok, _timer} = - App.Timer.start(%{ - item_id: 1, - start: "2023-01-19 15:52:00", - stop: "2023-01-19 15:52:03" - }) - - {:ok, _timer2} = - App.Timer.start(%{item_id: 1, start: "2023-01-19 15:55:00", stop: nil}) -end -``` -We are only adding sample data -when the server is being run in `dev` mode. # Done! ✅ diff --git a/lib/api/hoppscotch.md b/lib/api/hoppscotch.md new file mode 100644 index 00000000..156303f3 --- /dev/null +++ b/lib/api/hoppscotch.md @@ -0,0 +1,392 @@ +# _Advanced/Automated_ `API` Testing Using `Hoppscotch` + +`API` testing is an essential part +of the development lifecycle. +Incorporating tests will allow us +to avoid regressions +and make sure our `API` performs +the way it's supposed to. +In other words, +the `person` using the API +*expects* consistent responses to their requests. + +Integrating this into a +[CI pipeline](https://en.wikipedia.org/wiki/Continuous_integration) +automates this process +and helps avoiding unintentional breaking changes. + +We are going to be using +[`Hoppscotch`](https://github.com/hoppscotch/hoppscotch). +This is an open source tool +similar to [`Postman`](https://www.postman.com/) +that allow us to make requests, +organize them and create test suites. + +Read more about `Hoppscotch`: +[hoppscotch.io](https://hoppscotch.io) + +## `Hoppscotch` Setup + +There is no `App` to download, +but you can run `Hoppscotch` as +an "installable" [`PWA`](https://web.dev/what-are-pwas/): +![hoppscotch-docs-pwa](https://user-images.githubusercontent.com/194400/213877931-47344cfd-4dd7-491e-b032-9e65dff49ebc.png) + +In `Google Chrome` and `Microsoft Edge` +you will see an icon +in the Address bar to +"Install Hoppscotch app": + +image + +That will create what _looks_ like a "Native" App on your `Mac`: + +image + +Which then opens full-screen an _feels_ `Native`: + +Hoppscotch PWA Homescreen + +And you're all set to start testing the `API`. + +> Installing the `PWA` will _significantly_ increase your dev speed +because you can easily ``+`Tab` between your IDE and `Hoppscotch` +and not have to hunt for a Tab in your Web Browser. + +You can use `Hoppscotch` anonymously +(without logging in), +without any loss of functionality. + +If you decide to Authenticate +and you don't want to see the noise in the Top Nav, +simply enable "Zen Mode": + +![hoppscotch-zen-mode](https://user-images.githubusercontent.com/194400/213877013-0ff9c65d-10dc-4741-aa67-395e9fd6adb7.gif) + +With that out of the way, let's get started _using_ `Hoppscotch`! + + +## Using `Hoppscotch` + +When you first open `Hoppscotch`, +either in the browser or as a `PWA`, +you will not have anything defined: + +![hoppscotch-empty](https://user-images.githubusercontent.com/194400/213889044-0e38256d-0c59-41f0-bbbe-ee54a16583e2.png) + + +The _first_ thing to do is open an _existing_ collection: + +hoppscotch-open-collection + +Import from hoppscotch: `/lib/api/MVP.json` + +hoppscotch-open-local + +Collection imported: + +image + +_Next_ you'll need to open environment configuration / variables: + +hoppscotch-open-environment + + +![hoppscotch-open-env](https://user-images.githubusercontent.com/194400/213889224-45dd660e-874d-422c-913d-bfdba1052944.png) + +When you click on `Localhost`, you will see an `Edit Environment` Modal: + +image + +**environment variables** +let us switch +between development or production environments seamlessly. + +Even after you have imported the environment configuration file, +it's not automatically selected: + +hoppscotch-environment-not-found + +You need to **_manually_ select `Localhost`**. +With the "Environments" tab selected, click the "Select environment" selector and chose "Localhost": + +hoppscotch-select-environment-localhost + +Once you've selected the `Localhost` environment, the `<>` placeholder will turn from red to blue: + +image + +After importing the collection, +open the `MVP` and `Items` folder, +you will see a list of possible requests. + + +After importing the collection and environment, it _still_ won't work ... +image + +You will see the message: + +**Could not send request**. +Unable to reach the API endpoint. Check your network
connection or select a different interceptor and try again. + + +These are the available options: + +![image](https://user-images.githubusercontent.com/194400/213896782-b96d97a5-5e42-41ec-b299-e64c77246b79.png) + +If you select "Browser extension" it will open the Chrome web store where you can install the extension. + +Install the extension. +Once installed, +add the the `http://localhost:4000` origin: + +add endpoint + +Then the presence of the extension will be visible in the Hoppscotch window/UI: + +![image](https://user-images.githubusercontent.com/194400/213896932-a8f48f2a-f5ee-47c1-aad6-d9a09cf27b48.png) + +image + + +Now you can start testing the requests! +Start the Phoenix server locally +by running `mix s` + +The requests in the collection will _finally_ work: + +![image](https://user-images.githubusercontent.com/194400/213897127-c70a5961-1db6-4d1f-a944-cf08a5bf2f86.png) + + + +If you open `MVP, Items` +and try to `Get Item` (by clicking `Send`), +you will receive a response from the `localhost` server. + +get1 +get2 +get3 + +Depending if the `item` with `id=1` +(which is defined in the *env variable* `item_id` +in the `localhost` environment), +you will receive a successful response +or an error, detailing the error +that the item was not found with the given `id`. + +You can create **tests** for each request, +asserting the response object and HTTP code. +You can do so by clicking the `Tests` tab. + +test + +These tests are important to validate +the expected response of the API. +For further information +on how you can test the response in each request, +please visit their documentation at +https://docsapi.io/features/tests. + +## Integration with `Github Actions` with `Hoppscotch CLI` + +These tests can (and should!) +be used in CI pipelines. +To integrate this in our Github Action, +we will need to make some changes to our +[workflow file](https://docs.github.com/en/actions/using-workflows) +in `.github/worflows/ci.yml`. + +We want the [runner](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions#runners) +to be able to *execute* these tests. + +For this, we are going to be using +[**`Hoppscotch CLI`**](https://docs.hoppscotch.io/cli). + +With `hopp` (Hoppscotch CLI), +we will be able to run the collection of requests +and its tests in a command-line environment. + +To run the tests inside a command-line interface, +we are going to need two files: +- **environment file**, +a `json` file with each env variable as key +and its referring value. +For an example, +check the +[`lib/api/localhost.json` file](./lib/api/localhost.json). +- **collection file**, +the `json` file with all the requests. +It is the one you imported earlier. +You can export it the same way you imported it. +For an example, +check the +[`/lib/api/MVP.json` file](./lib/api/MVP.json). + +These files +will need to be pushed into the git repo. +The CI will need access to these files +to run `hopp` commands. + +In the case of our application, +for the tests to run properly, +we need some bootstrap data +so each request runs successfully. +For this, +we also added a +[`api_test_mock_data.sql`](lib/api/api_test_mock_data.sql) +`SQL` script file that will insert some mock data. + +### Changing the workflow `.yml` file + +It's time to add this API testing step +into our CI workflow! +For this, open `.github/workflows/ci.yml` +and add the following snippet of code +between the `build` and `deploy` jobs. + + +```yml + # API Definition testing + # https://docs.hoppscotch.io/cli + api_definition: + name: API Definition Tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:12 + ports: ['5432:5432'] + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + matrix: + otp: ['25.1.2'] + elixir: ['1.14.2'] + steps: + - uses: actions/checkout@v2 + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - name: Restore deps and _build cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} + - name: Install dependencies + run: mix deps.get + + - name: Install Hoppscotch CLI + run: npm i -g @hoppscotch/cli + + # Setups database and adds seed data for API definition tests + - name: Run mix setup + run: mix ecto.setup + env: + MIX_ENV: dev + AUTH_API_KEY: ${{ secrets.AUTH_API_KEY }} + + - name: Running server and Hoppscotch Tests + run: mix phx.server & sleep 5 && hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json +``` + +Let's breakdown what we just added. +We are running this job in a +[service container](https://docs.github.com/en/actions/using-containerized-services/about-service-containers) +that includes a PostgreSQL database - +similarly to the existent `build` job. + +We then install the `Hoppscotch CLI` +by running `npm i -g @hoppscotch/cli`. + +We then run `mix ecto.setup`. +This command creates the database, +runs the migrations +and executes `run priv/repo/seeds.exs`. +The list of commands is present +in the [`mix.exs` file](./mix.exs). + +We are going to change the `seeds.exs` +file to bootstrap the database +with sample data for the API tests to run. + + +At last, +we run the API by running `mix phx.server` +and execute `hopp test -e ./lib/api/localhost.json ./lib/api/MVP.json`. +This `hopp` command takes the environment file +and the collections file +and executes its tests. +You might notice we are using `sleep 5`. +This is because we want the `hopp` +command to be executed +after `mix phx.server` finishes initializing. + +And you should be done! +When running `hopp test`, +you will see the result of each request test. + +```sh +↳ API.Item.update/2, at: lib/api/item.ex:65 + 400 : Bad Request (0.049 s) +[info] Sent 400 in 4ms + ✔ Status code is 400 + Ran tests in 0.001 s + +Test Cases: 0 failed 31 passed +Test Suites: 0 failed 28 passed +Test Scripts: 0 failed 22 passed +Tests Duration: 0.041 s +``` + +If one test fails, the whole build fails, as well! + +### Changing the `priv/repo/seeds.exs` file + +As we mentioned prior, +the last thing we need to do is +to change our `priv/repo/seeds.exs` file +so it adds sample data for the tests to run +when calling `mix ecto.setup`. +Use the following piece of code +and change `seeds.exs` to look as such. + + +```elixir +if not Envar.is_set?("AUTH_API_KEY") do + Envar.load(".env") +end + +if Mix.env() == :dev do + App.Item.create_item(%{text: "random text", person_id: 0, status: 2}) + + {:ok, _timer} = + App.Timer.start(%{ + item_id: 1, + start: "2023-01-19 15:52:00", + stop: "2023-01-19 15:52:03" + }) + + {:ok, _timer2} = + App.Timer.start(%{item_id: 1, start: "2023-01-19 15:55:00", stop: nil}) +end +``` + +We are only adding sample data +when the server is being run in `dev` mode. + +# Troubleshooting + +If you get stuck while getting this setup, +please read through: +[dwyl/mvp/**issues/268**](https://github.com/dwyl/mvp/issues/268) +and leave a comment.