diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 258fbc46..d588eea6 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -143,7 +143,7 @@ defmodule Hex.RemoteConverger do def build_cooldown_bypass(_old_lock, _locked, :disabled), do: MapSet.new() def build_cooldown_bypass(old_lock, locked, _cutoff) do - # The bypass set has two sources: + # The bypass set has three sources: # # 1. Packages in `locked` (the post-`prepare_locked/3` set): the lockfile # entry survived requirement checking and dep unlocking, so this @@ -156,15 +156,37 @@ defmodule Hex.RemoteConverger do # to escape that version; cooldown must not block the escape. Walks # `old_lock` rather than `locked` because `mix deps.update foo` to # escape an unsafe foo removes foo from `locked`. + # + # 3. The currently-locked children of any package in (2): escaping an + # unsafe parent typically requires re-resolving its dependency + # subtree, and the new parent version usually wants newer versions of + # those children. Mirroring `mix deps.update`'s behavior of unlocking + # the children alongside the explicit target, we lift cooldown on the + # same set. lock_satisfied = for %{name: name} <- locked, into: MapSet.new(), do: name + MapSet.union(lock_satisfied, unsafe_lock_bypass(old_lock)) + end + + @doc """ + Set of package names whose cooldown should be lifted because the lock + currently points at an unsafe version of that package, plus the names + of that package's lock-children. + """ + def unsafe_lock_bypass(lock) do + Enum.reduce(lock, MapSet.new(), fn {_app, lock_entry}, acc -> + case Hex.Utils.lock(lock_entry) do + %{repo: repo, name: name, version: version, deps: deps} -> + if locked_version_unsafe?(repo || "hexpm", name, to_string(version)) do + children = for {_app, _req, opts} <- deps || [], do: opts[:hex] + acc |> MapSet.put(name) |> MapSet.union(MapSet.new(children)) + else + acc + end - unsafe = - for %{repo: repo, name: name, version: version} <- Hex.Mix.from_lock(old_lock), - locked_version_unsafe?(repo || "hexpm", name, to_string(version)), - into: MapSet.new(), - do: name - - MapSet.union(lock_satisfied, unsafe) + nil -> + acc + end + end) end defp locked_version_unsafe?(repo, name, version) do diff --git a/lib/mix/tasks/hex.outdated.ex b/lib/mix/tasks/hex.outdated.ex index 00736a12..62e95b7a 100644 --- a/lib/mix/tasks/hex.outdated.ex +++ b/lib/mix/tasks/hex.outdated.ex @@ -18,6 +18,12 @@ defmodule Mix.Tasks.Hex.Outdated do as specified in your `mix.exs` file, with the `--within-requirements` command line option, so it only exits with non-zero exit code if the update is possible. + If a `:cooldown` is configured (see `mix hex.config`) and the latest version of a + dependency falls within the cooldown window, the row is annotated with `(cooldown)` + and the version is listed under "Versions in cooldown". Cooldown-held updates do + not contribute to the non-zero exit code — `mix deps.update` would not pick them + up until they age out of the window. + For example, if your version requirement is "~> 2.0" but the latest version is `3.0`, with `--within-requirements` it will exit successfully, but if the latest version is `2.8`, then `--within-requirements` will exit with non-zero exit code (1). @@ -134,7 +140,7 @@ defmodule Mix.Tasks.Hex.Outdated do end defp display_table( - [{_package, _dep_only, current, latest, requirements, outdated?}], + [{_package, _dep_only, current, latest, requirements, outdated?, cooldown}], [_app], _opts ) do @@ -158,6 +164,8 @@ defmodule Mix.Tasks.Hex.Outdated do message = "Up-to-date indicates if the requirement matches the latest version." Hex.Shell.info(["\n", message]) + + maybe_print_single_cooldown(latest, cooldown) end defp display_table(versions, _args, opts) do @@ -171,6 +179,8 @@ defmodule Mix.Tasks.Hex.Outdated do header = ["Dependency", "Only", "Current", "Latest", "Status"] Mix.Tasks.Hex.print_table(header, values) + maybe_print_cooldown_legend(versions) + base_message = "Run `mix hex.outdated APP` to see requirements for a specific dependency." diff_message = maybe_diff_message(diff_links) diff_command_message = maybe_diff_command_message(diff_links) @@ -178,9 +188,44 @@ defmodule Mix.Tasks.Hex.Outdated do end end + defp maybe_print_single_cooldown(_latest, nil), do: :ok + + defp maybe_print_single_cooldown(latest, %{eligible_on: eligible_on}) do + days = Date.diff(eligible_on, Date.utc_today()) + Hex.Shell.info("\nVersion #{latest} is in cooldown — eligible #{eligible_on} (#{days} days)") + end + + defp maybe_print_cooldown_legend(versions) do + entries = + Enum.flat_map(versions, fn + {package, _, _, latest, _, _, %{eligible_on: eligible_on}} -> + [{package, latest, eligible_on}] + + _ -> + [] + end) + + case entries do + [] -> + :ok + + entries -> + today = Date.utc_today() + Hex.Shell.info("") + Hex.Shell.info("Versions in cooldown:") + + Enum.each(entries, fn {package, version, eligible_on} -> + days = Date.diff(eligible_on, today) + Hex.Shell.info(" #{package} #{version} — eligible #{eligible_on} (#{days} days)") + end) + end + end + defp set_exit_code(versions, args, opts) do outdated_versions = - Enum.filter(versions, fn {_p, _o, _l, _la, _r, outdated?} -> outdated? end) + Enum.filter(versions, fn {_p, _o, _l, _la, _r, outdated?, cooldown} -> + outdated? and is_nil(cooldown) + end) if outdated_versions != [] and exit_with_error?(outdated_versions, args, opts) do Mix.Tasks.Hex.set_exit_code(1) @@ -236,7 +281,7 @@ defmodule Mix.Tasks.Hex.Outdated do "Update possible" => 3 } - Enum.sort_by(values, fn [_package, _dep_only, _lock, _latest, [_color, status]] -> + Enum.sort_by(values, fn [_package, _dep_only, _lock, _latest, [_color, status | _]] -> Map.fetch!(status_order, status) end) end @@ -256,7 +301,7 @@ defmodule Mix.Tasks.Hex.Outdated do # deps can have multiple `only` values, so we separate by `,` only_values = String.split(only_value, ",", trim: true) - Enum.filter(versions, fn {_package, dep_only, _lock, _latest, _reqs, _outdated?} -> + Enum.filter(versions, fn {_package, dep_only, _lock, _latest, _reqs, _outdated?, _c} -> dep_only_parts = String.split(dep_only, ",") Enum.any?(dep_only_parts, &(&1 in only_values)) end) @@ -264,6 +309,13 @@ defmodule Mix.Tasks.Hex.Outdated do end defp get_versions(dep_names, deps, lock, pre?) do + cutoff = Hex.Cooldown.build_cutoff() + + bypass = + if cutoff == :disabled, + do: MapSet.new(), + else: Hex.RemoteConverger.unsafe_lock_bypass(lock) + Enum.flat_map(dep_names, fn name -> case Hex.Utils.lock(lock[name]) do %{repo: repo, name: package, version: lock_version} -> @@ -276,9 +328,17 @@ defmodule Mix.Tasks.Hex.Outdated do requirements = deps_requirements ++ lock_requirements dep_only = get_dep_only(deps, name) + cooldown = + cond do + not outdated? -> nil + not req_matches?(requirements, latest_version) -> nil + MapSet.member?(bypass, package) -> nil + true -> cooldown_info(repo, package, latest_version, cutoff) + end + [ {Atom.to_string(name), dep_only, lock_version, latest_version, requirements, - outdated?} + outdated?, cooldown} ] _ -> @@ -287,6 +347,25 @@ defmodule Mix.Tasks.Hex.Outdated do end) end + defp cooldown_info(_repo, _package, _version, :disabled), do: nil + + defp cooldown_info(repo, package, version, cutoff) do + if Hex.Cooldown.repo_excluded?(repo) do + nil + else + published_at = Registry.published_at(repo, package, version) + + if Hex.Cooldown.eligible?(published_at, cutoff) do + nil + else + %{ + published_at: published_at, + eligible_on: Hex.Cooldown.eligible_on(published_at, cutoff) + } + end + end + end + defp latest_version(repo, package, default, pre?) do {:ok, default} = Version.parse(default) pre? = pre? || default.pre != [] @@ -306,17 +385,19 @@ defmodule Mix.Tasks.Hex.Outdated do List.last(versions) end - defp format_all_row({package, dep_only, lock, latest, requirements, outdated?}) do + defp format_all_row({package, dep_only, lock, latest, requirements, outdated?, cooldown}) do latest_color = if outdated?, do: :red, else: :green req_matches? = req_matches?(requirements, latest) - status = + base_status = case {outdated?, req_matches?} do {true, true} -> [:yellow, "Update possible"] {true, false} -> [:red, "Update not possible"] {false, _} -> [:green, "Up-to-date"] end + status = if cooldown, do: base_status ++ [" (cooldown)"], else: base_status + [ [:bright, package], dep_only, @@ -326,7 +407,7 @@ defmodule Mix.Tasks.Hex.Outdated do ] end - defp build_diff_link({package, _dep_only, lock, latest, requirements, outdated?}) do + defp build_diff_link({package, _dep_only, lock, latest, requirements, outdated?, _cooldown}) do req_matches? = req_matches?(requirements, latest) case {outdated?, req_matches?} do @@ -369,7 +450,8 @@ defmodule Mix.Tasks.Hex.Outdated do end defp any_possible_to_update?(outdated_versions) do - Enum.any?(outdated_versions, fn {_package, _dep_only, _lock, latest, requirements, _outdated?} -> + Enum.any?(outdated_versions, fn {_package, _dep_only, _lock, latest, requirements, _outdated?, + _cooldown} -> req_matches?(requirements, latest) end) end @@ -394,7 +476,7 @@ defmodule Mix.Tasks.Hex.Outdated do |> Enum.join(",") end - defp cast_version_map({package, dep_only, lock, latest, requirements, outdated?}) do + defp cast_version_map({package, dep_only, lock, latest, requirements, outdated?, cooldown}) do %{ package: package, only: dep_only, @@ -408,7 +490,17 @@ defmodule Mix.Tasks.Hex.Outdated do up_to_date: version_match?(latest, req_version) } end), - outdated: outdated? + outdated: outdated?, + cooldown: cast_cooldown_map(cooldown) + } + end + + defp cast_cooldown_map(nil), do: nil + + defp cast_cooldown_map(%{published_at: published_at, eligible_on: eligible_on}) do + %{ + published_at: published_at |> DateTime.from_unix!() |> DateTime.to_iso8601(), + eligible_on: Date.to_iso8601(eligible_on) } end diff --git a/test/hex/remote_converger_cooldown_test.exs b/test/hex/remote_converger_cooldown_test.exs index 9cac26d4..958bb635 100644 --- a/test/hex/remote_converger_cooldown_test.exs +++ b/test/hex/remote_converger_cooldown_test.exs @@ -43,6 +43,15 @@ defmodule Hex.RemoteConvergerCooldownTest do {:hex, String.to_atom(name), version, "checksum", [:mix], [], repo, "outer_checksum"} end + defp lock_tuple_with_deps(name, version, child_names, repo \\ "hexpm") do + deps = + Enum.map(child_names, fn child -> + {String.to_atom(child), "~> 0.0.0", [hex: child, repo: "hexpm", optional: false]} + end) + + {:hex, String.to_atom(name), version, "checksum", [:mix], deps, repo, "outer_checksum"} + end + defp locked_request(name, version, repo \\ "hexpm") do %{repo: repo, name: name, app: name, version: version} end @@ -90,6 +99,31 @@ defmodule Hex.RemoteConvergerCooldownTest do old_lock = %{advised_dep: lock_tuple("advised_dep", "2.0.0")} assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) end + + test "lock-children of unsafe parent are also bypassed" do + # Escaping an unsafe parent typically requires re-resolving its + # dependency subtree, and the new parent version usually wants newer + # versions of those children. Mirror `mix deps.update`'s behavior of + # unlocking the children alongside the explicit target. + old_lock = %{ + retired_dep: lock_tuple_with_deps("retired_dep", "1.0.0", ["good", "advised_dep"]), + good: lock_tuple("good", "1.0.0") + } + + bypass = RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) + + assert MapSet.member?(bypass, "retired_dep") + assert MapSet.member?(bypass, "good") + assert MapSet.member?(bypass, "advised_dep") + end + + test "children of safe parents are not bypassed" do + old_lock = %{ + good: lock_tuple_with_deps("good", "1.0.0", ["advised_dep"]) + } + + assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) + end end describe "build_cooldown_bypass/3 — lock-satisfied bypass (spec C)" do diff --git a/test/mix/tasks/hex.outdated_test.exs b/test/mix/tasks/hex.outdated_test.exs index 2a4d302e..05b97b57 100644 --- a/test/mix/tasks/hex.outdated_test.exs +++ b/test/mix/tasks/hex.outdated_test.exs @@ -88,6 +88,29 @@ defmodule Mix.Tasks.Hex.OutdatedTest do end end + defmodule SingleDep.MixProject do + def project do + [ + app: :outdated_app, + version: "0.0.1", + deps: [{:foo, ">= 0.0.0"}] + ] + end + end + + defmodule UnsafeLockedDeps.MixProject do + def project do + [ + app: :outdated_app, + version: "0.0.1", + deps: [ + {:tired, ">= 0.0.0"}, + {:foo, ">= 0.0.0"} + ] + ] + end + end + defmodule OutdatedDepsWithTypes.MixProject do def project do [ @@ -608,7 +631,8 @@ defmodule Mix.Tasks.Hex.OutdatedTest do %{source: "ecto", requirement: "~> 0.0.1", up_to_date: false}, %{source: "postgrex", requirement: "0.0.1", up_to_date: false} ], - outdated: true + outdated: true, + cooldown: nil } ] |> :json.encode() @@ -895,4 +919,294 @@ defmodule Mix.Tasks.Hex.OutdatedTest do refute combined_output =~ "ex_doc" end) end + + describe "cooldown" do + defp inject_published_at(repo, package, version, published_at) do + :sys.replace_state(Hex.Registry.Server, fn state -> + :ets.insert(state.ets, {{:published_at, repo, package, version}, published_at}) + state + end) + end + + defp clear_all_published_at do + :sys.replace_state(Hex.Registry.Server, fn state -> + :ets.match_delete(state.ets, {{:published_at, :_, :_, :_}, :_}) + state + end) + end + + defp collect_shell_lines do + for {:mix_shell, :info, [line]} <- flush(), is_binary(line), do: line + end + + test "annotates row when latest version is within the cooldown window" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + + assert Enum.any?(lines, &(&1 =~ "foo" and &1 =~ "Update possible" and &1 =~ "(cooldown)")) + assert Enum.any?(lines, &(&1 == "Versions in cooldown:")) + assert Enum.any?(lines, &(&1 =~ "foo 0.1.1" and &1 =~ "eligible")) + end) + end + + test "does not annotate when latest version is older than the cooldown window" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 30 * 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + refute Enum.any?(lines, &(&1 =~ "(cooldown)")) + refute Enum.any?(lines, &(&1 == "Versions in cooldown:")) + end) + end + + test "does not annotate when cooldown is disabled" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "0d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + refute Enum.any?(lines, &(&1 =~ "(cooldown)")) + refute Enum.any?(lines, &(&1 == "Versions in cooldown:")) + end) + end + + test "treats missing published_at as eligible" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + Hex.State.put(:cooldown, "1mo") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + refute Enum.any?(lines, &(&1 =~ "(cooldown)")) + refute Enum.any?(lines, &(&1 == "Versions in cooldown:")) + end) + end + + test "does not annotate up-to-date dependencies even when fresh" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "bar", "0.1.0", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + refute Enum.any?(lines, &(&1 =~ "bar" and &1 =~ "(cooldown)")) + end) + end + + test "single-package mode annotates output and does not exit with error when latest is in cooldown" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert Mix.Task.run("hex.outdated", ["foo"]) == nil + + lines = collect_shell_lines() + assert Enum.any?(lines, &(&1 =~ "Version 0.1.1 is in cooldown" and &1 =~ "eligible")) + end) + end + + test "does not exit with error when every outdated dep is cooldown-held" do + Mix.Project.push(SingleDep.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert Mix.Task.run("hex.outdated") == nil + end) + end + + test "still exits with error when at least one outdated dep is not cooldown-held" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + end) + end + + test "--sort status orders cooldown-annotated rows by their base status" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all", "--sort", "status"])) == + {:exit_code, 1} + + assert extract_statuses(flush()) == [ + "Up-to-date", + "Update not possible", + "Update possible" + ] + end) + end + + test "does not annotate when locked version is retired (unsafe-lock bypass)" do + Mix.Project.push(UnsafeLockedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + # tired 0.1.0 is retired in setup_hexpm.exs; foo 0.1.0 is not. + Mix.Dep.Lock.write(%{ + tired: {:hex, :tired, "0.1.0"}, + foo: {:hex, :foo, "0.1.0"} + }) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "tired", "0.2.0", System.system_time(:second) - 86_400) + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + + refute Enum.any?(lines, &(&1 =~ "tired" and &1 =~ "(cooldown)")) + assert Enum.any?(lines, &(&1 =~ "foo" and &1 =~ "(cooldown)")) + end) + end + + test "does not annotate when the update is not possible due to requirements" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + # ex_doc's locked version is 0.0.1; OutdatedDeps requires ~> 0.0.1, + # which doesn't match the latest 0.1.0. Even with cooldown set on + # the latest, the user can't take the upgrade — don't annotate. + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "ex_doc", "0.1.0", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + + assert Enum.any?(lines, &(&1 =~ "ex_doc" and &1 =~ "Update not possible")) + refute Enum.any?(lines, &(&1 =~ "ex_doc" and &1 =~ "(cooldown)")) + refute Enum.any?(lines, &(&1 =~ "ex_doc 0.1.0" and &1 =~ "eligible")) + end) + end + + @tag :requires_json + test "JSON output exposes published_at and eligible_on when latest is in cooldown" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + Mix.Task.run("hex.outdated", ["foo", "--json"]) + + [json] = collect_shell_lines() + [decoded] = :json.decode(json) + assert %{"published_at" => _, "eligible_on" => _} = decoded["cooldown"] + end) + end + end end