diff --git a/lib/skate/detours/snapshot_serde.ex b/lib/skate/detours/snapshot_serde.ex index f17cbdcaa..ad6684ab4 100644 --- a/lib/skate/detours/snapshot_serde.ex +++ b/lib/skate/detours/snapshot_serde.ex @@ -289,8 +289,22 @@ defmodule Skate.Detours.SnapshotSerde do defp selectedreason_from_detour(_), do: nil - defp activated_at_from_detour(%Detour{activated_at: %DateTime{} = activated_at}), - do: DateTime.to_iso8601(activated_at) + defp activated_at_from_detour(%Detour{activated_at: %DateTime{} = activated_at}) do + activated_at + # For the time being, the frontend is responsible for generating the + # `activated_at` snapshot. Because browsers are limited to millisecond + # resolution and Ecto doesn't preserve the `milliseconds` field of a + # `DateTime`, we need to truncate the date if we want it to match what's in + # the stored snapshot. + # + # Once we're not trying to be equivalent with the stored snapshot, we could + # probably remove this. + # + # See `Skate.DetourFactory.browser_date/1` and `Skate.DetourFactory.db_date` + # for more context. + |> DateTime.truncate(:millisecond) + |> DateTime.to_iso8601() + end defp activated_at_from_detour(%Detour{activated_at: nil}), do: nil diff --git a/test/skate_web/controllers/detours_controller_test.exs b/test/skate_web/controllers/detours_controller_test.exs index df6672f26..bc2097dba 100644 --- a/test/skate_web/controllers/detours_controller_test.exs +++ b/test/skate_web/controllers/detours_controller_test.exs @@ -81,6 +81,23 @@ defmodule SkateWeb.DetoursControllerTest do assert Skate.Repo.aggregate(Notifications.Db.Detour, :count) == 1 end + @tag :authenticated + test "adds `activated_at` field when provided", %{conn: conn} do + %Skate.Detours.Db.Detour{id: id, state: snapshot, activated_at: nil} = insert(:detour) + + activated_at_time = + DateTime.utc_now() |> Skate.DetourFactory.browser_date() |> Skate.DetourFactory.db_date() + + put(conn, ~p"/api/detours/update_snapshot", %{ + "snapshot" => snapshot |> activated(activated_at_time) |> with_id(id) + }) + + Process.sleep(10) + + assert Skate.Detours.Detours.get_detour!(id).activated_at == + activated_at_time + end + @tag :authenticated test "does not create a new notification if detour was already activated", %{conn: conn} do setup_notification_server() @@ -238,11 +255,45 @@ defmodule SkateWeb.DetoursControllerTest do put(conn, "/api/detours/update_snapshot", %{"snapshot" => detour_snapshot}) - conn = get(conn, "/api/detours/#{detour_id}") + {conn, log} = + CaptureLog.with_log(fn -> + get(conn, "/api/detours/#{detour_id}") + end) + + refute log =~ + "Serialized detour doesn't match saved snapshot. Falling back to snapshot for detour_id=#{detour_id}" assert detour_snapshot == json_response(conn, 200)["data"]["state"] end + @tag :authenticated + test "serialized snapshot `activatedAt` value is formatted as iso-8601", %{conn: conn} do + activated_at = Skate.DetourFactory.browser_date() + + %{id: id} = + detour = + :detour + |> build() + |> activated(activated_at) + |> insert() + + # Make ID match snapshot + detour + |> Skate.Detours.Detours.change_detour(detour |> update_id() |> Map.from_struct()) + |> Skate.Repo.update!() + + {conn, log} = + CaptureLog.with_log(fn -> + get(conn, "/api/detours/#{id}") + end) + + refute log =~ + "Serialized detour doesn't match saved snapshot. Falling back to snapshot for detour_id=#{id}" + + assert DateTime.to_iso8601(activated_at) == + json_response(conn, 200)["data"]["state"]["context"]["activatedAt"] + end + @tag :authenticated test "log an error if the serialized detour does not match db state", %{conn: conn} do detour_id = 4 diff --git a/test/support/factories/detour_factory.ex b/test/support/factories/detour_factory.ex index 0087b1174..723f3f261 100644 --- a/test/support/factories/detour_factory.ex +++ b/test/support/factories/detour_factory.ex @@ -57,12 +57,27 @@ defmodule Skate.DetourFactory do put_in(snapshot["context"]["uuid"], id) end - def activated(%Skate.Detours.Db.Detour{} = detour) do - %{detour | state: activated(detour.state)} + def update_id(%Skate.Detours.Db.Detour{id: id} = detour) do + with_id(detour, id) end - def activated(%{"value" => %{}} = state) do - put_in(state["value"], %{"Detour Drawing" => %{"Active" => "Reviewing"}}) + def activated(update_arg, activated_at \\ DateTime.utc_now()) + + def activated(%Skate.Detours.Db.Detour{} = detour, activated_at) do + activated_at = Skate.DetourFactory.browser_date(activated_at) + %{detour | state: activated(detour.state, activated_at), activated_at: activated_at} + end + + def activated(%{"value" => %{}, "context" => %{}} = state, activated_at) do + state = + put_in(state["value"], %{"Detour Drawing" => %{"Active" => "Reviewing"}}) + + put_in( + state["context"]["activatedAt"], + activated_at + |> Skate.DetourFactory.browser_date() + |> DateTime.to_iso8601() + ) end def deactivated(%Skate.Detours.Db.Detour{} = detour) do @@ -95,4 +110,32 @@ defmodule Skate.DetourFactory do end end end + + @doc """ + Browsers cannot generate javascript `Date` objects with more precision than a + `millisecond` for security reasons. + https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now#reduced_time_precision + + This function truncates a `DateTime` to milliseconds to create `DateTime` objects + that are similar to that of one made in a Browser JS context. + """ + def browser_date(%DateTime{} = date \\ DateTime.utc_now()) do + DateTime.truncate(date, :millisecond) + end + + @doc """ + While a Browser may generate a date truncated to milliseconds + (see `browser_date` for more context) a `DateTime` stored into Postgres with + the `:utc_datetime_usec` type does not store the extra information about the + non-presence of nanoseconds that a `DateTime` object does. + This means a `DateTime` object that's been truncated by `browser_date` cannot + be compared to a `DateTime` object reconstructed by Ecto after a Database query. + + This function adds 0 nanoseconds to a `DateTime` object to make the `DateTime` + object match what Ecto would return to make testing easier when comparing + values. + """ + def db_date(%DateTime{} = date) do + DateTime.add(date, 0, :nanosecond) + end end