diff --git a/API.md b/API.md new file mode 100644 index 00000000..95d0ac8b --- /dev/null +++ b/API.md @@ -0,0 +1,919 @@ +
PM|AM)", + "P" => "(?
pm|am)", + "%" => "%" + } + + @doc """ + Parse string to datetime struct + **Example**: + parse("2021-20-10", "%Y-%M-%d") + Support format + | format | description| value example | + | -- | -- | -- | + | H | 24 hour | 00 - 23 | + | I | 12 hour | 00 - 12 | + | M | minute| 00 - 59 | + | S | second | 00 - 59 | + | d | day | 01 - 31 | + | m | month | 01 -12 | + | y | 2 digits year | 00 - 99 | + | Y | 4 digits year | | + | z | timezone offset | +0100, -0330 | + | Z | timezone name | UTC+7, Asia/Ho_Chi_Minh | + | p | PM or AM | | + | P | pm or am | | + """ + def parse!(dt_string, format \\ "%Y-%m-%dT%H:%M:%SZ") do + case parse(dt_string, format) do + {:ok, dt} -> + dt + + {:error, message} -> + raise "Parse string #{dt_string} with error: #{message}" + end + end + + @doc """ + Parses the string according to the format. + Pipes through regex compilation, casts each part of the string + to a named regex capture and tries to convert to datetime. + """ + def parse(dt_string, format \\ "%Y-%m-%dT%H:%M:%SZ") do + format + |> build_regex + |> Regex.named_captures(dt_string) + |> cast_data + |> to_datetime + end + + @doc """ + Builds the regex expression to later be captured (have named key-value captures).any() + This uses the @mapping structure to name specific parts of the entered string to convert to datetime. + """ + def build_regex(format) do + keys = Map.keys(@mapping) |> Enum.join("") + + Regex.compile!("([^%]*)%([#{keys}])([^%]*)") + |> Regex.scan(format) + |> Enum.map(fn [_, s1, key, s2] -> + [s1, Map.get(@mapping, key), s2] + end) + |> to_string() + |> Regex.compile!() + end + + @default_value %{ + day: 1, + month: 1, + year: 0, + hour: 0, + minute: 0, + second: 0, + utc_offset: 0, + tz_name: "UTC", + shift: "AM" + } + def cast_data(nil), do: {:error, "invalid datetime"} + + @doc """ + Casts each capture of the regex to appropriated format (compatible with DateTime struct) + """ + def cast_data(captures) do + captures + |> Enum.reduce_while([], fn {part, value}, acc -> + {:ok, data} = cast(part, value) + {:cont, [data | acc]} + end) + |> Enum.into(@default_value) + end + + @value_rages %{ + "hour" => [0, 23], + "hour12" => [0, 12], + "minute" => [0, 59], + "second" => [0, 59], + "day" => [0, 31], + "month" => [1, 12], + "year2" => [0, 99] + } + + defp cast("P", value) do + cast("p", String.upcase(value)) + end + + defp cast("p", value) do + {:ok, {:shift, value}} + end + + defp cast("tz", value) do + {hour, minute} = String.split_at(value, 3) + + with {:ok, {_, hour}} <- cast("offset_h", hour), + {:ok, {_, minute}} <- cast("offset_m", minute) do + sign = div(hour, abs(hour)) + {:ok, {:utc_offset, sign * (abs(hour) * 3600 + minute * 60)}} + end + end + + defp cast("tz_name", value) do + {:ok, {:tz_name, value}} + end + + defp cast(part, value) do + value = String.to_integer(value) + + valid = + case Map.get(@value_rages, part) do + [min, max] -> + value >= min and value <= max + + _ -> + true + end + + if valid do + {:ok, {String.to_atom(part), value}} + end + end + + defp to_datetime({:error, _} = error), do: error + + defp to_datetime(%{year2: value} = data) do + current_year = DateTime.utc_now() |> Map.get(:year) + year = div(current_year, 100) * 100 + value + + data + |> Map.put(:year, year) + |> Map.delete(:year2) + |> to_datetime() + end + + defp to_datetime(%{hour12: hour} = data) do + # 12AM is not valid + + if hour == 12 and data.shift == "AM" do + {:error, "12AM is invalid value"} + else + hour = + cond do + hour == 12 and data.shift == "PM" -> hour + data.shift == "AM" -> hour + data.shift == "PM" -> hour + 12 + end + + data + |> Map.put(:hour, hour) + |> Map.delete(:hour12) + |> to_datetime() + end + end + + defp to_datetime(data) do + with {:ok, date} <- Date.new(data.year, data.month, data.day), + {:ok, time} <- Time.new(data.hour, data.minute, data.second), + {:ok, datetime} <- DateTime.new(date, time) do + datetime = DateTime.add(datetime, -data.utc_offset, :second) + + if data.tz_name != "UTC" do + {:error, "Only UTC timezone is available"} + else + {:ok, datetime} + end + end + end +end diff --git a/lib/app/item.ex b/lib/app/item.ex index dec9e6c6..002fcaea 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -7,6 +7,7 @@ defmodule App.Item do alias __MODULE__ require Logger + @derive {Jason.Encoder, only: [:id, :person_id, :status, :text]} schema "items" do field :person_id, :integer field :status, :integer @@ -66,7 +67,7 @@ defmodule App.Item do end @doc """ - Gets a single item. + `get_item!/1` gets a single Item. Raises `Ecto.NoResultsError` if the Item does not exist. @@ -85,6 +86,25 @@ defmodule App.Item do |> Repo.preload(tags: from(t in Tag, order_by: t.text)) end + @doc """ + `get_item/1` gets a single Item. + + Returns nil if the Item does not exist + + ## Examples + + iex> get_item(1) + %Timer{} + + iex> get_item(1313) + nil + """ + def get_item(id) do + Item + |> Repo.get(id) + |> Repo.preload(tags: from(t in Tag, order_by: t.text)) + end + @doc """ Returns the list of items where the status is different to "deleted" diff --git a/lib/app/timer.ex b/lib/app/timer.ex index 4ce52433..87a16a4e 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -1,12 +1,11 @@ defmodule App.Timer do use Ecto.Schema - import Ecto.Changeset - alias App.Item - import Ecto.Query - alias App.Repo + import Ecto.{Changeset, Query} + alias App.{Item, Repo} alias __MODULE__ require Logger + @derive {Jason.Encoder, only: [:id, :start, :stop]} schema "timers" do field :start, :naive_datetime field :stop, :naive_datetime @@ -15,6 +14,24 @@ defmodule App.Timer do timestamps() end + 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 + @doc false def changeset(timer, attrs) do timer @@ -23,7 +40,7 @@ defmodule App.Timer do end @doc """ - `get_timer/1` gets a single Timer. + `get_timer!/1` gets a single Timer. Raises `Ecto.NoResultsError` if the Timer does not exist. @@ -34,6 +51,45 @@ defmodule App.Timer do """ def get_timer!(id), do: Repo.get!(Timer, id) + @doc """ + `get_timer/1` gets a single Timer. + + Returns nil if the Timer does not exist + + ## Examples + + iex> get_timer(1) + %Timer{} + + iex> get_item!(13131) + nil + """ + def get_timer(id), do: Repo.get(Timer, id) + + @doc """ + `list_timers/1` lists all the timer objects of a given item `id`. + + ## Examples + + iex> list_timers(1) + [ + %App.Timer{ + id: 7, + start: ~N[2023-01-11 17:40:44], + stop: nil, + item_id: 1, + inserted_at: ~N[2023-01-11 18:01:43], + updated_at: ~N[2023-01-11 18:01:43] + } + ] + """ + def list_timers(item_id) do + Timer + |> where(item_id: ^item_id) + |> order_by(:id) + |> Repo.all() + end + @doc """ `start/1` starts a timer. @@ -46,6 +102,7 @@ defmodule App.Timer do def start(attrs \\ %{}) do %Timer{} |> changeset(attrs) + |> validate_start_before_stop() |> Repo.insert() end @@ -73,9 +130,10 @@ 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() end @@ -119,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, []} _ -> @@ -218,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/router.ex b/lib/app_web/router.ex index 62b88847..e61db8b2 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -10,6 +10,11 @@ defmodule AppWeb.Router do plug :put_secure_browser_headers end + pipeline :api do + plug :accepts, ["json"] + plug :fetch_session + end + # No Auth scope "/", AppWeb do pipe_through :browser @@ -17,7 +22,9 @@ defmodule AppWeb.Router do get "/login", AuthController, :login end - pipeline :authOptional, do: plug(AuthPlugOptional) + pipeline :authOptional do + plug(AuthPlugOptional) + end scope "/", AppWeb do pipe_through [:browser, :authOptional] @@ -26,4 +33,13 @@ defmodule AppWeb.Router do live "/stats", StatsLive resources "/tags", TagController, except: [:show] end + + scope "/api", API do + pipe_through [:api, :authOptional] + + resources "/items", Item, only: [:create, :update, :show] + + resources "/items/:item_id/timers", Timer, + only: [:create, :update, :show, :index] + end end diff --git a/mix.exs b/mix.exs index 64f8d96a..389cf8cd 100644 --- a/mix.exs +++ b/mix.exs @@ -72,8 +72,7 @@ defmodule App.MixProject do {:fields, "~> 2.10.3"}, # Useful functions: github.com/dwyl/useful - {:useful, "~> 1.0.8"}, - + {:useful, "~> 1.0.8", override: true}, # See: github.com/dwyl/useful/issues/17 {:atomic_map, "~> 0.9.3"}, @@ -82,8 +81,7 @@ defmodule App.MixProject do # create docs on localhost by running "mix docs" {:ex_doc, "~> 0.29", only: :dev, runtime: false}, - - # Track test coverage + # Track test coverage: github.com/parroty/excoveralls {:excoveralls, "~> 0.15", only: [:test, :dev]}, # git pre-commit hook runs tests before allowing commits diff --git a/test/api/item_test.exs b/test/api/item_test.exs new file mode 100644 index 00000000..5e6868f0 --- /dev/null +++ b/test/api/item_test.exs @@ -0,0 +1,71 @@ +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 diff --git a/test/api/timer_test.exs b/test/api/timer_test.exs new file mode 100644 index 00000000..4f61e3c5 --- /dev/null +++ b/test/api/timer_test.exs @@ -0,0 +1,131 @@ +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 + 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 diff --git a/test/app/timer_test.exs b/test/app/timer_test.exs index b42d1a64..b842bdf0 100644 --- a/test/app/timer_test.exs +++ b/test/app/timer_test.exs @@ -89,12 +89,36 @@ 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) assert updated_timer.start == start assert updated_timer.stop == stop end + + test "update_timer(%{id: id, start: start, stop: stop}) with start later than stop should throw error" do + start = ~N[2022-10-27 05:00:00] + stop = ~N[2022-10-27 00:00:00] + + {:ok, %{model: item, version: _version}} = + Item.create_item(@valid_item_attrs) + + {:ok, seven_seconds_ago} = + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) + + # Start the timer 7 seconds ago: + {:ok, timer} = + Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago}) + + # Stop the timer based on its item_id + Timer.stop_timer_for_item_id(item.id) + + # Update timer with stop earlier than start + {:error, changeset} = + Timer.update_timer(timer, %{start: start, stop: stop}) + + assert length(changeset.errors) > 0 + end end end diff --git a/test/app_web/live/app_live_test.exs b/test/app_web/live/app_live_test.exs index 579a351a..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], @@ -643,7 +652,7 @@ defmodule AppWeb.AppLiveTest do assert "/" = redirected_to(conn, 302) end - test "test login link redirect to auth.dwyl.com", %{conn: conn} do + test "test login link redirect to auth", %{conn: conn} do conn = get(conn, "/login") assert redirected_to(conn, 302) =~ "auth"