From 8e40c337ffbe45de01e27619dc5bc9c9d9ebc2b3 Mon Sep 17 00:00:00 2001 From: Anthony Shull Date: Thu, 14 Mar 2024 08:18:33 -0500 Subject: [PATCH] Flush caches by key (#1914) * flush cache by key * use scan instead of keys command * docs * docs * docs * cms cache controller test and other test stubs * only need two of these * tests * cleaner test * correct cursor * more communicative tests * scan on each node * cms cache entries --- .envrc.template | 8 +- config/config.exs | 4 + config/runtime.exs | 10 +-- config/test.exs | 3 + lib/cms/repo.ex | 16 ++-- lib/dotcom/cache/multilevel.ex | 86 +++++++++++++++++-- lib/dotcom/cache/publisher.ex | 3 +- lib/dotcom/cache/subscriber.ex | 7 +- lib/dotcom/redis/behaviour.ex | 13 +++ lib/dotcom/redix/behaviour.ex | 17 ++++ lib/dotcom/redix/pub_sub/behaviour.ex | 19 ++++ .../controllers/cache_controller.ex | 46 ++++++++++ lib/dotcom_web/controllers/cms_controller.ex | 24 ------ lib/dotcom_web/router.ex | 6 +- test/dotcom/cache/multilevel_test.exs | 49 +++++++++++ test/dotcom/cache/publisher_test.exs | 59 +++++++++++++ test/dotcom/cache/subscriber_test.exs | 65 ++++++++++++++ .../controllers/cache_controller_test.exs | 28 ++++++ .../controllers/cms_controller_test.exs | 23 ----- test/support/mocks.ex | 5 ++ 20 files changed, 413 insertions(+), 78 deletions(-) create mode 100644 lib/dotcom/redis/behaviour.ex create mode 100644 lib/dotcom/redix/behaviour.ex create mode 100644 lib/dotcom/redix/pub_sub/behaviour.ex create mode 100644 lib/dotcom_web/controllers/cache_controller.ex create mode 100644 test/dotcom/cache/multilevel_test.exs create mode 100644 test/dotcom/cache/publisher_test.exs create mode 100644 test/dotcom/cache/subscriber_test.exs create mode 100644 test/dotcom_web/controllers/cache_controller_test.exs create mode 100644 test/support/mocks.ex diff --git a/.envrc.template b/.envrc.template index dc2bf29607..c684df1a6f 100644 --- a/.envrc.template +++ b/.envrc.template @@ -35,7 +35,7 @@ export WIREMOCK_TRIP_PLAN_PROXY_URL=http://otp-local.mbtace.com # export REDIS_HOST= # export REDIS_PORT= -# These credentials control access to resetting cache entries for the CMS. -# You can set them to be whatever you want, but they'll need to match those on the Drupal side. -# export CMS_BASIC_AUTH_USERNAME= -# export CMS_BASIC_AUTH_PASSWORD= +# These credentials control access to resetting cache entries. +# You can set them to be whatever you want, but they'll need to match external users like Drupal. +# export BASIC_AUTH_USERNAME= +# export BASIC_AUTH_PASSWORD= diff --git a/config/config.exs b/config/config.exs index 3141ac44ca..e0fd009629 100644 --- a/config/config.exs +++ b/config/config.exs @@ -2,6 +2,10 @@ import Config config :elixir, ansi_enabled: true +config :dotcom, :redis, Dotcom.Cache.Multilevel.Redis +config :dotcom, :redix, Redix +config :dotcom, :redix_pub_sub, Redix.PubSub + for config_file <- Path.wildcard("config/{deps,dotcom}/*.exs") do import_config("../#{config_file}") end diff --git a/config/runtime.exs b/config/runtime.exs index ed48fed7ec..2ca4053f7f 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -80,7 +80,7 @@ redis_config = [ ] # This is used by PubSub, we only use the first node in the cluster -config :dotcom, :redis, redis_config[:redis_cluster][:configuration_endpoints][:conn_opts] +config :dotcom, :redis_config, redis_config[:redis_cluster][:configuration_endpoints][:conn_opts] # Set caches that use the Redis cluster config :dotcom, Dotcom.Cache.Multilevel, @@ -95,15 +95,15 @@ config :dotcom, Dotcom.Cache.TripPlanFeedback.Cache, redis_config if config_env() == :test do config :dotcom, DotcomWeb.Router, - cms_basic_auth: [ + basic_auth: [ username: "username", password: "password" ] else config :dotcom, DotcomWeb.Router, - cms_basic_auth: [ - username: System.get_env("CMS_BASIC_AUTH_USERNAME"), - password: System.get_env("CMS_BASIC_AUTH_PASSWORD") + basic_auth: [ + username: System.get_env("BASIC_AUTH_USERNAME"), + password: System.get_env("BASIC_AUTH_PASSWORD") ] end diff --git a/config/test.exs b/config/test.exs index 8974de9e2c..71f464fc64 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,4 +1,7 @@ import Config config :dotcom, :cache, Dotcom.Cache.TestCache +config :dotcom, :redis, Redis.Mock +config :dotcom, :redix, Redix.Mock +config :dotcom, :redix_pub_sub, Redix.PubSub.Mock config :dotcom, :trip_plan_feedback_cache, Dotcom.Cache.TestCache diff --git a/lib/cms/repo.ex b/lib/cms/repo.ex index 52205bda48..a75f3a4a08 100644 --- a/lib/cms/repo.ex +++ b/lib/cms/repo.ex @@ -102,7 +102,7 @@ defmodule CMS.Repo do @decorate cacheable( cache: @cache, - key: "/cms/whats-happening", + key: "cms.repo|whats-happening", on_error: :nothing, opts: [ttl: 60_000] ) @@ -122,7 +122,7 @@ defmodule CMS.Repo do @decorate cacheable( cache: @cache, - key: "/cms/important-notices", + key: "cms.repo|important-notices", on_error: :nothing, opts: [ttl: 60_000] ) @@ -161,7 +161,7 @@ defmodule CMS.Repo do @decorate cacheable( cache: @cache, - key: "/cms/schedules/#{route_id}", + key: "cms.repo|schedules|#{route_id}", on_error: :nothing, opts: [ttl: @ttl] ) @@ -193,7 +193,7 @@ defmodule CMS.Repo do @decorate cacheable( cache: @cache, - key: "/cms/route_pdfs/#{route_id}", + key: "cms.repo|route_pdfs|#{route_id}", on_error: :nothing, opts: [ttl: @ttl] ) @@ -221,11 +221,15 @@ defmodule CMS.Repo do @impl true def generate(_, _, [path, %Plug.Conn.Unfetched{aspect: :query_params}]) do - "/cms/#{String.trim(path, "/")}" + key = path |> String.trim("/") |> String.replace(~r/\//, "|") + + "cms.repo|#{key}" end def generate(_, _, [path, params]) do - "/cms/#{String.trim(path, "/")}" <> params_to_string(params) + key = path |> String.trim("/") |> String.replace(~r/\//, "|") + + "cms.repo|#{key}" <> params_to_string(params) end defp params_to_string(params) when params == %{}, do: "" diff --git a/lib/dotcom/cache/multilevel.ex b/lib/dotcom/cache/multilevel.ex index 67e7e3b925..dea17e41ca 100644 --- a/lib/dotcom/cache/multilevel.ex +++ b/lib/dotcom/cache/multilevel.ex @@ -31,27 +31,95 @@ defmodule Dotcom.Cache.Multilevel do use Nebulex.Cache, otp_app: :dotcom, adapter: Dotcom.Cache.Publisher end + @cache Application.compile_env!(:dotcom, :cache) + @redix Application.compile_env!(:dotcom, :redix) + @doc """ - To flush the cache, we get all *shared* keys in Redis and delete them. - These deletes will be published to the Publisher, which will then delete the keys in the Local caches. + Delete all entries where the key matches the pattern. + + First, we make sure we can get a connection to Redis. + Second, we get all of the nodes in the cluster. + + For each node: + + We get all the keys in Redis that match the pattern. + Then, we use a cursor to stream the keys in batches of 100 using the SCAN command. + Finally, we delete all the keys with the default delete/1 function. + That way we'll delete from the Local, Redis, and publish the delete on the Publisher. """ def flush_keys(pattern \\ "*") do - case Application.get_env(:dotcom, :redis) |> Redix.start_link() do - {:ok, conn} -> flush_redis_keys(conn, pattern) + case Application.get_env(:dotcom, :redis_config) |> @redix.start_link() do + {:ok, conn} -> (delete_from_nodes(conn, pattern) ++ [@redix.stop(conn)]) |> all_ok() {:error, _} -> :error end end - defp flush_redis_keys(conn, pattern) do - case Redix.command(conn, ["KEYS", pattern]) do - {:ok, keys} -> delete_keys(conn, keys) + defp all_ok(list) do + if list + |> List.flatten() + |> Enum.all?(fn + :ok -> true + _ -> false + end) do + :ok + else + :error + end + end + + defp delete_from_node([host, port], pattern) do + case @redix.start_link(host: host, port: port) do + {:ok, conn} -> delete_stream_keys(conn, pattern) {:error, _} -> :error end end + defp delete_from_nodes(conn, pattern) do + case get_nodes(conn) do + [] -> :ok + nodes -> Enum.map(nodes, fn node -> delete_from_node(node, pattern) end) + end + end + defp delete_keys(conn, keys) do - Enum.each(keys, fn key -> __MODULE__.delete(key) end) + results = Enum.map(keys, fn key -> @cache.delete(key) end) + + result = @redix.stop(conn) + + [result | results] + end + + defp delete_stream_keys(conn, pattern) do + case stream_keys(conn, pattern) |> Enum.to_list() |> List.flatten() do + [] -> :ok + keys -> delete_keys(conn, keys) + end + end + + defp get_nodes(conn) do + case @redix.command(conn, ["CLUSTER", "SLOTS"]) do + {:ok, slots} -> + slots + |> Enum.flat_map(fn slots -> Enum.slice(slots, 2..99) end) + |> Enum.map(fn slot -> Enum.slice(slot, 0..1) end) + |> Enum.sort_by(fn [_, port] -> port end) + + {:error, _} -> + [] + end + end + + defp scan_for_keys(conn, pattern, cursor) do + case @redix.command(conn, ["SCAN", cursor, "MATCH", pattern, "COUNT", 100]) do + {:ok, [new_cursor, keys]} -> {keys, if(new_cursor == "0", do: :stop, else: new_cursor)} + {:error, _} -> {[], :stop} + end + end - Redix.stop(conn) + defp stream_keys(conn, pattern) do + Stream.unfold("0", fn + :stop -> nil + cursor -> scan_for_keys(conn, pattern, cursor) + end) end end diff --git a/lib/dotcom/cache/publisher.ex b/lib/dotcom/cache/publisher.ex index 7756463d8b..a626894456 100644 --- a/lib/dotcom/cache/publisher.ex +++ b/lib/dotcom/cache/publisher.ex @@ -17,6 +17,7 @@ defmodule Dotcom.Cache.Publisher do alias Nebulex.Adapter.Stats @channel "dotcom:cache:publisher" + @redis Application.compile_env!(:dotcom, :redis) def channel, do: @channel @@ -72,7 +73,7 @@ defmodule Dotcom.Cache.Publisher do def delete(meta, key, _) do command = "eviction" - Dotcom.Cache.Multilevel.Redis.command([ + @redis.command([ "PUBLISH", @channel, "#{command}|#{meta.publisher_id}|#{key}" diff --git a/lib/dotcom/cache/subscriber.ex b/lib/dotcom/cache/subscriber.ex index bee4eb8d25..2fcf5c47a6 100644 --- a/lib/dotcom/cache/subscriber.ex +++ b/lib/dotcom/cache/subscriber.ex @@ -14,6 +14,7 @@ defmodule Dotcom.Cache.Subscriber do @executions %{ "eviction" => :delete } + @redix_pub_sub Application.compile_env!(:dotcom, :redix_pub_sub) def start_link(uuid) do GenServer.start_link(__MODULE__, uuid, []) @@ -25,8 +26,8 @@ defmodule Dotcom.Cache.Subscriber do Starts a Redix.PubSub process and subscribes to the channel given by the Publisher. """ def init(uuid) do - Application.get_env(:dotcom, :redis) - |> Redix.PubSub.start_link() + Application.get_env(:dotcom, :redis_config) + |> @redix_pub_sub.start_link() |> subscribe(@channel) {:ok, uuid} @@ -67,6 +68,6 @@ defmodule Dotcom.Cache.Subscriber do end defp subscribe({:ok, pubsub}, channel) do - Redix.PubSub.subscribe(pubsub, channel, self()) + @redix_pub_sub.subscribe(pubsub, channel, self()) end end diff --git a/lib/dotcom/redis/behaviour.ex b/lib/dotcom/redis/behaviour.ex new file mode 100644 index 0000000000..cd031d0432 --- /dev/null +++ b/lib/dotcom/redis/behaviour.ex @@ -0,0 +1,13 @@ +defmodule Dotcom.Redis.Behaviour do + @moduledoc """ + A behaviour module for NebulexRedisAdapter. + """ + + @callback command(Redix.command()) :: + {:ok, Redix.Protocol.redis_value()} + | {:error, atom() | Redix.Error.t() | Redix.ConnectionError.t()} + + @implementation Application.compile_env!(:dotcom, :redis) + + def command(cmd), do: @implementation.command(cmd) +end diff --git a/lib/dotcom/redix/behaviour.ex b/lib/dotcom/redix/behaviour.ex new file mode 100644 index 0000000000..4a1fe6fe9e --- /dev/null +++ b/lib/dotcom/redix/behaviour.ex @@ -0,0 +1,17 @@ +defmodule Dotcom.Redix.Behaviour do + @moduledoc """ + A behaviour module for Redix. + """ + + @callback command(Redix.connection(), Redix.command()) :: + {:ok, Redix.Protocol.redis_value()} + | {:error, atom() | Redix.Error.t() | Redix.ConnectionError.t()} + @callback start_link(String.t() | keyword()) :: {:ok, pid()} | :ignore | {:error, term()} + @callback stop(Redix.connection()) :: :ok + + @implementation Application.compile_env!(:dotcom, :redix) + + def command(conn, cmd), do: @implementation.command(conn, cmd) + def start_link(opts), do: @implementation.start_link(opts) + def stop(conn), do: @implementation.stop(conn) +end diff --git a/lib/dotcom/redix/pub_sub/behaviour.ex b/lib/dotcom/redix/pub_sub/behaviour.ex new file mode 100644 index 0000000000..3eb3b6d612 --- /dev/null +++ b/lib/dotcom/redix/pub_sub/behaviour.ex @@ -0,0 +1,19 @@ +defmodule Dotcom.Redix.PubSub.Behaviour do + @moduledoc """ + A behaviour module for Redix.PubSub. + """ + + @callback start_link(String.t() | keyword()) :: {:ok, pid()} | :ignore | {:error, term()} + @callback subscribe( + Redix.PubSub.connection(), + String.t() | [String.t()], + Redix.PubSub.subscriber() + ) :: {:ok, reference()} + + @implementation Application.compile_env!(:dotcom, :redix_pub_sub) + + def start_link(opts), do: @implementation.start_link(opts) + + def subscribe(conn, channels, subscriber), + do: @implementation.subscribe(conn, channels, subscriber) +end diff --git a/lib/dotcom_web/controllers/cache_controller.ex b/lib/dotcom_web/controllers/cache_controller.ex new file mode 100644 index 0000000000..0432d2276f --- /dev/null +++ b/lib/dotcom_web/controllers/cache_controller.ex @@ -0,0 +1,46 @@ +defmodule DotcomWeb.CacheController do + @moduledoc """ + A controller that allows us to interact with the cache. + Currently, we only support deleting keys from the cache. + """ + + require Logger + + use DotcomWeb, :controller + + @cache Application.compile_env!(:dotcom, :cache) + + @doc """ + Flushes the cache given a key in the path. + Simply use a / in the path where you would use a | in the key. + Wildcards are supported. + + Examples: + + /cache/stops.repo/stop/* -> stops.repo|stop|* + /cache/stops.repo/stop/1 -> stops.repo|stop|1 + """ + def flush_cache_keys(conn, %{"path" => path}) do + key = Enum.join(path, "|") + + try do + Kernel.apply(@cache, flush_cache_function(@cache), [key]) + rescue + e in Redix.ConnectionError -> + Logger.warning("dotcom_web.cache_controller.error error=redis-#{e.reason}") + + e in Redix.Error -> + Logger.warning("dotcom_web.cache_controller.error error=redis-#{e.message}") + end + + send_resp(conn, 202, "") |> halt() + end + + defp flush_cache_function(cache) do + if cache.__info__(:functions) |> Keyword.has_key?(:flush_keys) do + :flush_keys + else + :delete + end + end +end diff --git a/lib/dotcom_web/controllers/cms_controller.ex b/lib/dotcom_web/controllers/cms_controller.ex index 4ba6397116..6ab599dadc 100644 --- a/lib/dotcom_web/controllers/cms_controller.ex +++ b/lib/dotcom_web/controllers/cms_controller.ex @@ -34,8 +34,6 @@ defmodule DotcomWeb.CMSController do Page.ProjectUpdate ] - @cache Application.compile_env!(:dotcom, :cache) - @spec page(Conn.t(), map) :: Conn.t() def page(%Conn{request_path: path, query_params: query_params} = conn, _params) do conn = Conn.assign(conn, :try_encoded_on_404?, Map.has_key?(query_params, "id")) @@ -45,28 +43,6 @@ defmodule DotcomWeb.CMSController do |> handle_page_response(conn) end - @doc """ - Resets a cache key based on the URL params. - The path after /cms is joined with / to form the cache key. - So, it can be of arbitrary length. - PATCH /cms/foo/bar/baz will reset the cache key /cms/foo/bar/baz. - This corresponds to the CMS page /foo/bar/baz. - """ - def reset_cache_key(conn, %{"path" => path}) do - joined_path = Enum.join(path, "/") - - try do - @cache.delete("/cms/#{joined_path}") - - Logger.notice("cms.cache.delete path=/cms/#{joined_path}") - rescue - e in Redix.ConnectionError -> Logger.warning("cms.cache.delete error=redis-#{e.reason}") - e in Redix.Error -> Logger.warning("cms.cache.delete error=redis-#{e.message}") - end - - send_resp(conn, 202, "") |> halt() - end - @spec handle_page_response(Page.t() | {:error, API.error()}, Conn.t()) :: Plug.Conn.t() defp handle_page_response(%{__struct__: struct} = page, conn) diff --git a/lib/dotcom_web/router.ex b/lib/dotcom_web/router.ex index 2c3034c530..3bc7be5a4b 100644 --- a/lib/dotcom_web/router.ex +++ b/lib/dotcom_web/router.ex @@ -54,10 +54,10 @@ defmodule DotcomWeb.Router do get("/_health", HealthController, :index) end - scope "/cms", DotcomWeb do + scope "/cache", DotcomWeb do pipe_through([:basic_auth]) - patch("/*path", CMSController, :reset_cache_key) + delete("/*path", CacheController, :flush_cache_keys) end # redirect 't.mbta.com' and 'beta.mbta.com' to 'https://www.mbta.com' @@ -283,7 +283,7 @@ defmodule DotcomWeb.Router do end defp basic_auth(conn, _) do - opts = Application.get_env(:dotcom, DotcomWeb.Router)[:cms_basic_auth] + opts = Application.get_env(:dotcom, DotcomWeb.Router)[:basic_auth] Plug.BasicAuth.basic_auth(conn, opts) end diff --git a/test/dotcom/cache/multilevel_test.exs b/test/dotcom/cache/multilevel_test.exs new file mode 100644 index 0000000000..e494e2c9ff --- /dev/null +++ b/test/dotcom/cache/multilevel_test.exs @@ -0,0 +1,49 @@ +defmodule Dotcom.Cache.MultilevelTest do + use ExUnit.Case, async: false + + import Mox + + setup :set_mox_global + + setup :verify_on_exit! + + @cache Application.compile_env!(:dotcom, :cache) + + describe "flush_keys" do + test "deletes all keys that match the pattern" do + @cache.put("foo|bar", "baz") + @cache.put("foo|baz", "bar") + @cache.put("bar|foo", "baz") + + pattern = "foo|*" + + # We start the link to get the cluster nodes + expect(Redix.Mock, :start_link, fn _ -> {:ok, 0} end) + # We get the cluster nodes + expect(Redix.Mock, :command, fn _, ["CLUSTER", "SLOTS"] -> + {:ok, [[0, 1, ["127.0.0.1", 6379]]]} + end) + + # One node is returned so we start a link to it + expect(Redix.Mock, :start_link, fn _ -> {:ok, 1} end) + # The first scan returns one key and gives us a cursor to continue + expect(Redix.Mock, :command, fn _, ["SCAN", "0", "MATCH", ^pattern, _, _] -> + {:ok, ["1", ["foo|bar"]]} + end) + + # The second scan returns one key and gives us a stop cursor "0" + expect(Redix.Mock, :command, fn _, ["SCAN", "1", "MATCH", ^pattern, _, _] -> + {:ok, ["0", ["foo|baz"]]} + end) + + # We stop the connection to the node we got the list of nodes from as well as each node we operated on + expect(Redix.Mock, :stop, 2, fn _ -> :ok end) + + assert Dotcom.Cache.Multilevel.flush_keys(pattern) == :ok + + assert @cache.get("foo|bar") == nil + assert @cache.get("foo|baz") == nil + assert @cache.get("bar|foo") == "baz" + end + end +end diff --git a/test/dotcom/cache/publisher_test.exs b/test/dotcom/cache/publisher_test.exs new file mode 100644 index 0000000000..8f8ea0da98 --- /dev/null +++ b/test/dotcom/cache/publisher_test.exs @@ -0,0 +1,59 @@ +defmodule Dotcom.Cache.PublisherTest do + use ExUnit.Case, async: false + + import Mox + + defmodule Cache do + use Nebulex.Cache, otp_app: :dotcom, adapter: Dotcom.Cache.Publisher + end + + setup :set_mox_global + + setup do + expect(Redix.PubSub.Mock, :start_link, fn _ -> {:ok, 0} end) + expect(Redix.PubSub.Mock, :subscribe, fn _, _, _ -> {:ok, 0} end) + + {:ok, pid} = Cache.start_link(stats: true, telemetry: true) + + on_exit(fn -> + :ok = Process.sleep(100) + + if Process.alive?(pid), do: Cache.stop(pid) + end) + + {:ok, cache: Cache} + end + + setup :verify_on_exit! + + describe "delete" do + test "publishes cache eviction messages to the Redis PubSub channel" do + expect(Redis.Mock, :command, fn ["PUBLISH", _, _] -> :ok end) + + Cache.delete("foo") + end + end + + describe "stats" do + test "increments the evictions counter" do + assert Cache.stats().measurements.evictions == 0 + + expect(Redis.Mock, :command, fn ["PUBLISH", _, _] -> :ok end) + + Cache.delete("foo") + + assert Cache.stats().measurements.evictions == 1 + end + + test "resets the evictions counter" do + assert Cache.stats().measurements.evictions == 0 + + expect(Redis.Mock, :command, fn ["PUBLISH", _, _] -> :ok end) + + Cache.delete("foo") + + assert Cache.stats().measurements.evictions == 1 + assert Cache.stats().measurements.evictions == 0 + end + end +end diff --git a/test/dotcom/cache/subscriber_test.exs b/test/dotcom/cache/subscriber_test.exs new file mode 100644 index 0000000000..e756ca725c --- /dev/null +++ b/test/dotcom/cache/subscriber_test.exs @@ -0,0 +1,65 @@ +defmodule Dotcom.Cache.SubscriberTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + import Mox + + setup :set_mox_global + + setup do + expect(Redix.PubSub.Mock, :start_link, fn _ -> {:ok, 0} end) + expect(Redix.PubSub.Mock, :subscribe, fn _, _, _ -> {:ok, 0} end) + + uuid = UUID.uuid4() + + {:ok, pid} = GenServer.start_link(Dotcom.Cache.Subscriber, uuid) + + %{pid: pid, uuid: uuid} + end + + setup :verify_on_exit! + + @cache Application.compile_env!(:dotcom, :cache) + @channel Dotcom.Cache.Publisher.channel() + + describe "handle_info" do + test "returns the state if we get a subscription message", %{uuid: uuid} do + {:noreply, state} = + Dotcom.Cache.Subscriber.handle_info( + {:redix_pubsub, nil, nil, :subscribed, %{channel: @channel}}, + uuid + ) + + assert state == uuid + end + + test "executes the command", %{uuid: uuid} do + @cache.put("foo", "bar") + + msg = {:redix_pubsub, nil, nil, :message, %{channel: @channel, payload: "eviction|0|foo"}} + + {:noreply, _} = Dotcom.Cache.Subscriber.handle_info(msg, uuid) + + assert @cache.get("foo") == nil + end + end + + test "does not execute the command if the subscriber is the publisher", %{uuid: uuid} do + @cache.put("foo", "bar") + + msg = + {:redix_pubsub, nil, nil, :message, %{channel: @channel, payload: "eviction|#{uuid}|foo"}} + + {:noreply, _} = Dotcom.Cache.Subscriber.handle_info(msg, uuid) + + refute @cache.get("foo") == nil + end + + test "does not execute the command if the command is not in the execution list", %{uuid: uuid} do + msg = {:redix_pubsub, nil, nil, :message, %{channel: @channel, payload: "foo|0|key"}} + + log = capture_log(fn -> Dotcom.Cache.Subscriber.handle_info(msg, uuid) end) + + assert log =~ "unknown_command command=foo" + end +end diff --git a/test/dotcom_web/controllers/cache_controller_test.exs b/test/dotcom_web/controllers/cache_controller_test.exs new file mode 100644 index 0000000000..405e22d6f9 --- /dev/null +++ b/test/dotcom_web/controllers/cache_controller_test.exs @@ -0,0 +1,28 @@ +defmodule DotcomWeb.CacheControllerTest do + use DotcomWeb.ConnCase, async: false + + @cache Application.compile_env!(:dotcom, :cache) + + describe "DELETE /cache/*path" do + test "it removes an entry from the cache", %{conn: conn} do + paths = ["/cache/foo", "/cache/foo/bar"] + + Enum.each(paths, fn path -> + key = String.replace(path, "/cache/", "") |> String.split("/") |> Enum.join("|") + + @cache.put(key, "foo") + + assert @cache.get(key) != nil + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Basic " <> Base.encode64("username:password")) + |> delete(path) + + assert @cache.get(key) == nil + assert conn.status == 202 + end) + end + end +end diff --git a/test/dotcom_web/controllers/cms_controller_test.exs b/test/dotcom_web/controllers/cms_controller_test.exs index 04c0f7def7..b7e48325ae 100644 --- a/test/dotcom_web/controllers/cms_controller_test.exs +++ b/test/dotcom_web/controllers/cms_controller_test.exs @@ -3,8 +3,6 @@ defmodule DotcomWeb.CMSControllerTest do alias Plug.Conn - @cache Application.compile_env!(:dotcom, :cache) - describe "GET - page" do test "renders a basic page when the CMS returns a CMS.Page.Basic", %{conn: conn} do conn = get(conn, "/basic_page_no_sidebar") @@ -184,25 +182,4 @@ defmodule DotcomWeb.CMSControllerTest do assert html_response(conn, 500) =~ "Something went wrong on our end." end end - - describe "PATCH /cms/*path" do - test "it removes an entry from the cache", %{conn: conn} do - paths = ["/cms/foo", "/cms/foo/bar", "/cms/foo/bar/baz"] - - Enum.each(paths, fn path -> - @cache.put(path, "foo") - - assert @cache.get(path) != nil - - conn = - conn - |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", "Basic " <> Base.encode64("username:password")) - |> patch(path) - - assert @cache.get(path) == nil - assert conn.status == 202 - end) - end - end end diff --git a/test/support/mocks.ex b/test/support/mocks.ex new file mode 100644 index 0000000000..5d7924818d --- /dev/null +++ b/test/support/mocks.ex @@ -0,0 +1,5 @@ +# This file houses definitions for defining Mox mocks. + +Mox.defmock(Redis.Mock, for: Dotcom.Redis.Behaviour) +Mox.defmock(Redix.Mock, for: Dotcom.Redix.Behaviour) +Mox.defmock(Redix.PubSub.Mock, for: Dotcom.Redix.PubSub.Behaviour)