diff --git a/front/.gitignore b/front/.gitignore index d9c60540c..8faef7e07 100644 --- a/front/.gitignore +++ b/front/.gitignore @@ -43,3 +43,8 @@ cover/ lcov* docker-compose.override.yml + +# Nix +.nix-mix +.nix-hex +.direnv diff --git a/front/AGENTS.md b/front/AGENTS.md index 13bdbd0e6..cc0bda612 100644 --- a/front/AGENTS.md +++ b/front/AGENTS.md @@ -7,6 +7,27 @@ - `test/` mirrors `lib/` with ExUnit suites, Wallaby browser specs in `test/browser`, and fixtures under `test/fixture`. - `priv/` serves runtime assets, and `workflow_templates/` supplies seeded YAML pipelines consumed by the UI. +## Environment: Nix + direnv + +This repo uses **Nix flakes** with **direnv** for development environments. The `flake.nix` and `.envrc` configure Elixir 1.15, Erlang 26, and Node 22. + +### Running commands + +Shell tools like `mix`, `elixir`, `node`, etc. are only available inside the Nix dev shell. To run commands: + +```bash +# Option 1: Use direnv (loads automatically when you cd into the directory) +cd front && direnv allow && mix compile + +# Option 2: Use nix develop directly +nix develop front/ -c mix compile + +# Option 3: Prefix with direnv exec +direnv exec front mix compile +``` + +**Important:** Do not run `mix`, `elixir`, or other app-specific tools from the repo root — they won't be on PATH. Always `cd` into the app directory or use `direnv exec`. + ## Build, Test, and Development Commands - First-time setup: `mix deps.get` and `npm install --prefix assets`. - `make dev.server` (Docker) launches Phoenix with Redis, RabbitMQ, and demo data preloaded. diff --git a/front/Dockerfile b/front/Dockerfile index f02f5e6b9..047938a0f 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -1,6 +1,6 @@ -ARG ELIXIR_VERSION=1.14.5 -ARG OTP_VERSION=25.3.2.21 -ARG ALPINE_VERSION=3.22.2 +ARG ELIXIR_VERSION=1.15.7 +ARG OTP_VERSION=26.2.5.17 +ARG ALPINE_VERSION=3.22.3 ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-alpine-${ALPINE_VERSION}" ARG RUNNER_IMAGE="alpine:${ALPINE_VERSION}" diff --git a/front/docker-compose.yml b/front/docker-compose.yml index d4ce8ef37..2d4981905 100644 --- a/front/docker-compose.yml +++ b/front/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.6' +version: "3.6" services: app: @@ -69,5 +69,8 @@ services: rabbitmq: image: rabbitmq:3-management + ports: + - "5672:5672" + - "15672:15672" environment: RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit log_levels [{connection,error}]" diff --git a/front/flake.lock b/front/flake.lock new file mode 100644 index 000000000..bf24a075c --- /dev/null +++ b/front/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1771848320, + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/front/flake.nix b/front/flake.nix new file mode 100644 index 000000000..134cfd763 --- /dev/null +++ b/front/flake.nix @@ -0,0 +1,44 @@ +{ + description = "Billing Elixir development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + erlang = pkgs.beam.packages.erlang_26; + elixir = erlang.elixir_1_15; + in + { + devShells.default = pkgs.mkShell { + packages = [ + elixir + pkgs.erlang_26 + pkgs.nodejs_22 + pkgs.gnumake + pkgs.chromedriver + ]; + + shellHook = '' + export MIX_HOME="$PWD/.nix-mix" + export HEX_HOME="$PWD/.nix-hex" + export PATH="$MIX_HOME/bin:$HEX_HOME/bin:$PATH" + export ERL_AFLAGS="-kernel shell_history enabled" + + # Create dirs if they don't exist + mkdir -p "$MIX_HOME" "$HEX_HOME" + + # Install hex and rebar if not present + if [ ! -f "$MIX_HOME/rebar3" ]; then + mix local.hex --force --if-missing + mix local.rebar --force --if-missing + fi + ''; + }; + }); +} diff --git a/front/lib/front/feature_provider_invalidator_worker.ex b/front/lib/front/feature_provider_invalidator_worker.ex index 49d2d28da..3376a223f 100644 --- a/front/lib/front/feature_provider_invalidator_worker.ex +++ b/front/lib/front/feature_provider_invalidator_worker.ex @@ -1,59 +1,97 @@ defmodule Front.FeatureProviderInvalidatorWorker do + use Broadway + require Logger - @doc """ - This module consumes RabbitMQ feature and machine state change events - and invalidates features and machines caches. - """ - - use Tackle.Multiconsumer, - url: Application.get_env(:front, :amqp_url), - service: "front", - routes: [ - {"feature_exchange", "machines_changed", :machines_changed}, - {"feature_exchange", "organization_machines_changed", :organization_machines_changed}, - {"feature_exchange", "features_changed", :features_changed}, - {"feature_exchange", "organization_features_changed", :organization_features_changed} - ], - # This queue is used to consume events from the feature exchange. - # It is declared as non-durable, auto-delete and exclusive. - # This means that the queue will be deleted when the consumer disconnects. - # This is the desired behavior, because these events are used to invalidate pod-level caches. - queue: :dynamic, - queue_opts: [ - durable: false, - auto_delete: true, - exclusive: true - ], - connection_id: Front.FeatureProviderInvalidatorWorker - - def machines_changed(_message) do + @routing_keys ~w( + machines_changed + organization_machines_changed + features_changed + organization_features_changed + ) + + def start_link(_opts) do + Broadway.start_link(__MODULE__, + name: __MODULE__, + producer: [ + module: + {BroadwayRabbitMQ.Producer, + queue: "", + connection: amqp_url(), + after_connect: fn channel -> + AMQP.Exchange.declare(channel, "feature_exchange", :direct, durable: true) + end, + declare: [ + durable: false, + auto_delete: true, + exclusive: true + ], + bindings: + Enum.map(@routing_keys, fn rk -> + {"feature_exchange", routing_key: rk} + end), + on_failure: :reject, + metadata: [:routing_key]}, + concurrency: 1 + ], + processors: [ + default: [ + concurrency: 1, + max_demand: 1 + ] + ] + ) + end + + @impl true + def handle_message(_processor, message, _context) do + case message.metadata.routing_key do + "machines_changed" -> + handle_machines_changed(message.data) + + "organization_machines_changed" -> + handle_organization_machines_changed(message.data) + + "features_changed" -> + handle_features_changed(message.data) + + "organization_features_changed" -> + handle_organization_features_changed(message.data) + + unknown -> + Logger.warning("[FEATURE PROVIDER INVALIDATOR WORKER] unknown routing key: #{unknown}") + end + + message + end + + defp handle_machines_changed(_payload) do log("invalidating machines") {:ok, _} = FeatureProvider.list_machines(reload: true) - :ok end - def organization_machines_changed(message) do - event = InternalApi.Feature.OrganizationMachinesChanged.decode(message) + defp handle_organization_machines_changed(payload) do + event = InternalApi.Feature.OrganizationMachinesChanged.decode(payload) log("invalidating machines for org #{event.org_id}") {:ok, _} = FeatureProvider.list_machines(reload: true, param: event.org_id) - :ok end - def features_changed(_message) do + defp handle_features_changed(_payload) do log("invalidating features") {:ok, _} = FeatureProvider.list_features(reload: true) - :ok end - def organization_features_changed(message) do - event = InternalApi.Feature.OrganizationFeaturesChanged.decode(message) + defp handle_organization_features_changed(payload) do + event = InternalApi.Feature.OrganizationFeaturesChanged.decode(payload) log("invalidating features for org #{event.org_id}") {:ok, _} = FeatureProvider.list_features(reload: true, param: event.org_id) - :ok end defp log(message) do Logger.info("[FEATURE PROVIDER INVALIDATOR WORKER] #{message}") end + + defp amqp_url do + Application.get_env(:front, :amqp_url) + end end diff --git a/front/lib/front/models/project_metrics.ex b/front/lib/front/models/project_metrics.ex index aab1f5151..60a21b4fc 100644 --- a/front/lib/front/models/project_metrics.ex +++ b/front/lib/front/models/project_metrics.ex @@ -175,7 +175,7 @@ defmodule Front.Models.ProjectMetrics do API.MetricAggregation.value(:RANGE) _ -> - Logger.warn("Unknown aggregate: '#{value}', defaulting to range") + Logger.warning("Unknown aggregate: '#{value}', defaulting to range") API.MetricAggregation.value(:RANGE) end end diff --git a/front/lib/front/models/service_account.ex b/front/lib/front/models/service_account.ex index 47ed949bb..ad7234424 100644 --- a/front/lib/front/models/service_account.ex +++ b/front/lib/front/models/service_account.ex @@ -146,7 +146,7 @@ defmodule Front.Models.ServiceAccount do end) |> Enum.map(fn {member, nil} -> - Logger.warn("Service account #{member.id} not found in service accounts list") + Logger.warning("Service account #{member.id} not found in service accounts list") nil {member, service_account} -> diff --git a/front/lib/front/rbac/role_management.ex b/front/lib/front/rbac/role_management.ex index da6d809c1..c3ea79a1c 100644 --- a/front/lib/front/rbac/role_management.ex +++ b/front/lib/front/rbac/role_management.ex @@ -208,7 +208,7 @@ defmodule Front.RBAC.RoleManagement do InternalApi.RBAC.SubjectType.value(:USER) _ -> - Logger.warn("Unrecognized subject type: #{subject_type}, defaulting to user") + Logger.warning("Unrecognized subject type: #{subject_type}, defaulting to user") InternalApi.RBAC.SubjectType.value(:USER) end diff --git a/front/lib/front_web/controllers/deployments_controller.ex b/front/lib/front_web/controllers/deployments_controller.ex index d43f3c9f8..64f980736 100644 --- a/front/lib/front_web/controllers/deployments_controller.ex +++ b/front/lib/front_web/controllers/deployments_controller.ex @@ -49,7 +49,7 @@ defmodule FrontWeb.DeploymentsController do render_page(conn, "show.html", target_details, %{page_args: page_args}) else {:exit, {%GRPC.RPCError{status: @grpc_not_found}, _stacktrace}} -> - Logger.warn("[DT] Target not found: target_id=#{target_id}") + Logger.warning("[DT] Target not found: target_id=#{target_id}") render_404(conn) end end) @@ -75,7 +75,7 @@ defmodule FrontWeb.DeploymentsController do render_page(conn, "edit.html", changeset, resources) else {:exit, {%GRPC.RPCError{status: @grpc_not_found}, _stacktrace}} -> - Logger.warn("[DT] Target not found: target_id=#{target_id}") + Logger.warning("[DT] Target not found: target_id=#{target_id}") render_404(conn) end end) @@ -142,7 +142,7 @@ defmodule FrontWeb.DeploymentsController do |> render_page("edit.html", changeset, resources) {:error, %GRPC.RPCError{status: @grpc_not_found}} -> - Logger.warn("[DT] Target not found: target_id=#{target_id}") + Logger.warning("[DT] Target not found: target_id=#{target_id}") conn |> put_flash(:alert, "Failure: deployment target was not found") @@ -186,11 +186,11 @@ defmodule FrontWeb.DeploymentsController do |> redirect(to: deployments_path(conn, :index, project_name)) else {:exit, {%GRPC.RPCError{status: @grpc_not_found}, _stacktrace}} -> - Logger.warn("[DT] Target not found: target_id=#{target_id}") + Logger.warning("[DT] Target not found: target_id=#{target_id}") render_404(conn) {:error, %GRPC.RPCError{status: @grpc_not_found}} -> - Logger.warn("[DT] Target not found: target_id=#{target_id}") + Logger.warning("[DT] Target not found: target_id=#{target_id}") conn |> put_flash(:alert, "Failure: deployment target was not found") @@ -220,7 +220,7 @@ defmodule FrontWeb.DeploymentsController do |> redirect(to: deployments_path(conn, :index, project_name)) else {:exit, {%GRPC.RPCError{status: @grpc_not_found}, _stacktrace}} -> - Logger.warn("[DT] Target not found: target_id=#{target_id}") + Logger.warning("[DT] Target not found: target_id=#{target_id}") render_404(conn) {:error, reason} -> diff --git a/front/lib/front_web/controllers/secrets_controller.ex b/front/lib/front_web/controllers/secrets_controller.ex index 83e500ac1..a4bd314f0 100644 --- a/front/lib/front_web/controllers/secrets_controller.ex +++ b/front/lib/front_web/controllers/secrets_controller.ex @@ -177,7 +177,7 @@ defmodule FrontWeb.SecretsController do } require Logger - Logger.warn("validation errors: #{inspect(validation_errors)}") + Logger.warning("validation errors: #{inspect(validation_errors)}") conn |> put_flash(:alert, compose_alert_message(validation_errors)) diff --git a/front/lib/front_web/controllers/workflow_controller.ex b/front/lib/front_web/controllers/workflow_controller.ex index c779d12e8..e32f9555d 100644 --- a/front/lib/front_web/controllers/workflow_controller.ex +++ b/front/lib/front_web/controllers/workflow_controller.ex @@ -224,11 +224,11 @@ defmodule FrontWeb.WorkflowController do {:error, workflow_files_error_message(error, hook)} {:ok, {:error, reason}} -> - Logger.warn("[workflow.edit] Unable to start fetching job: #{inspect(reason)}") + Logger.warning("[workflow.edit] Unable to start fetching job: #{inspect(reason)}") {:error, workflow_files_error_message(reason, hook)} {:exit, reason} -> - Logger.warn("[workflow.edit] Fetching job crashed: #{inspect(reason)}") + Logger.warning("[workflow.edit] Fetching job crashed: #{inspect(reason)}") {:error, workflow_files_error_message(reason, hook)} end end @@ -243,11 +243,11 @@ defmodule FrontWeb.WorkflowController do {:error, workflow_files_error_message(error, hook)} {:ok, {:error, reason}} -> - Logger.warn("[workflow.edit] Unable to load workflow files: #{inspect(reason)}") + Logger.warning("[workflow.edit] Unable to load workflow files: #{inspect(reason)}") {:error, workflow_files_error_message(reason, hook)} {:exit, reason} -> - Logger.warn("[workflow.edit] Fetching workflow files crashed: #{inspect(reason)}") + Logger.warning("[workflow.edit] Fetching workflow files crashed: #{inspect(reason)}") {:error, workflow_files_error_message(reason, hook)} end end diff --git a/front/lib/front_web/plugs/cache_control.ex b/front/lib/front_web/plugs/cache_control.ex index 1de9b99e6..806cb5b3a 100644 --- a/front/lib/front_web/plugs/cache_control.ex +++ b/front/lib/front_web/plugs/cache_control.ex @@ -42,7 +42,7 @@ defmodule FrontWeb.Plugs.CacheControl do ) conn -> - Logger.warn("Invalid #{__MODULE__} header option: #{inspect(option)}") + Logger.warning("Invalid #{__MODULE__} header option: #{inspect(option)}") conn end diff --git a/front/lib/front_web/views/pipeline_view.ex b/front/lib/front_web/views/pipeline_view.ex index 787a66e0f..54f6d0e21 100644 --- a/front/lib/front_web/views/pipeline_view.ex +++ b/front/lib/front_web/views/pipeline_view.ex @@ -313,7 +313,7 @@ defmodule FrontWeb.PipelineView do Map.get(pipeline, :triggerer, :none) |> case do :none -> - Logger.warn("Pipeline #{pipeline.id} has no triggerer") + Logger.warning("Pipeline #{pipeline.id} has no triggerer") action_string(conn, workflow, pipeline) triggerer -> diff --git a/front/mix.exs b/front/mix.exs index eb7d0c3fc..80de078b9 100644 --- a/front/mix.exs +++ b/front/mix.exs @@ -5,7 +5,7 @@ defmodule Front.Mixfile do [ app: :front, version: "0.0.1", - elixir: "~> 1.12", + elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, @@ -40,6 +40,7 @@ defmodule Front.Mixfile do {:poison, "~> 6.0", override: true}, {:gettext, "~> 0.11"}, {:grpc, "0.5.0-beta.1", override: true}, + {:gun, "~> 2.0.0", hex: "grpc_gun", override: true}, {:cowboy, "~> 2.9.0", override: true}, {:cowlib, "~> 2.11.0", override: true}, {:watchman, github: "renderedtext/ex-watchman"}, @@ -63,6 +64,8 @@ defmodule Front.Mixfile do {:typed_struct, "~> 0.1.4"}, {:amqp_client, "~> 3.9.27"}, {:tackle, github: "renderedtext/ex-tackle", tag: "v0.2.3"}, + {:broadway, "~> 1.1"}, + {:broadway_rabbitmq, "~> 0.7"}, {:jsx, "~> 2.9", override: true}, {:csv, "~> 2.3"}, {:crontab, "~> 1.1.10"}, diff --git a/front/mix.lock b/front/mix.lock index 5a3b7258e..9a01c6460 100644 --- a/front/mix.lock +++ b/front/mix.lock @@ -1,6 +1,8 @@ %{ "amqp": {:hex, :amqp, "3.3.0", "056d9f4bac96c3ab5a904b321e70e78b91ba594766a1fc2f32afd9c016d9f43b", [:mix], [{:amqp_client, "~> 3.9", [hex: :amqp_client, repo: "hexpm", optional: false]}], "hexpm", "8d3ae139d2646c630d674a1b8d68c7f85134f9e8b2a1c3dd5621616994b10a8b"}, "amqp_client": {:hex, :amqp_client, "3.9.29", "e523658a1437cc34a4acd3a6b98c640f64149e5e94ca399ec029b4770b7ecf98", [:make, :rebar3], [{:rabbit_common, "3.9.29", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm", "75b4f3c26d794fcafc82ceb9e245b3dca958a3a5fa60ff9ce26c879397fe77a6"}, + "broadway": {:hex, :broadway, "1.2.1", "83a1567423c26885e15f6cd8670ca790370af2fcff2ede7fa88c5ea793087a67", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68ae63d83b55bdca0f95cd49feee5fb74c5a6bec557caf940860fe07dbc8a4fb"}, + "broadway_rabbitmq": {:hex, :broadway_rabbitmq, "0.8.2", "087e2fb0ea2fe6fd941246be6985eccda93ea601bf678c3e8bd5d2a830acb058", [:mix], [{:amqp, "~> 1.3 or ~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :amqp, repo: "hexpm", optional: false]}, {:broadway, "~> 1.0", [hex: :broadway, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.5 or ~> 0.4.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "762cf2fa7c20027c20ebaf1708570c2101c78824237eb2ab7e3f1158e4003a5a"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "cacheman": {:git, "https://github.com/renderedtext/ex-cacheman.git", "0a40c2f73800755f10ba511263e156f360dd3759", []}, "cachex": {:hex, :cachex, "3.4.0", "868b2959ea4aeb328c6b60ff66c8d5123c083466ad3c33d3d8b5f142e13101fb", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "370123b1ab4fba4d2965fb18f87fd758325709787c8c5fce35b3fe80645ccbe5"}, diff --git a/front/test/front/feature_provider_invalidator_worker_test.exs b/front/test/front/feature_provider_invalidator_worker_test.exs new file mode 100644 index 000000000..75d265cdd --- /dev/null +++ b/front/test/front/feature_provider_invalidator_worker_test.exs @@ -0,0 +1,104 @@ +defmodule Front.FeatureProviderInvalidatorWorkerTest do + use Front.TestCase + + import ExUnit.CaptureLog + import Mock + + alias Front.FeatureProviderInvalidatorWorker, as: Worker + + defp build_message(routing_key, data \\ "") do + %Broadway.Message{ + data: data, + metadata: %{routing_key: routing_key}, + acknowledger: Broadway.NoopAcknowledger.init() + } + end + + describe "handle_message/3" do + test "machines_changed invalidates global machine cache" do + message = build_message("machines_changed") + + with_mock FeatureProvider, list_machines: fn _opts -> {:ok, []} end do + log = + capture_log(fn -> + result = Worker.handle_message(:default, message, %{}) + assert result == message + end) + + assert log =~ "invalidating machines" + assert_called(FeatureProvider.list_machines(reload: true)) + end + end + + test "organization_machines_changed invalidates org machine cache" do + org_id = UUID.uuid4() + + payload = + InternalApi.Feature.OrganizationMachinesChanged.new(org_id: org_id) + |> InternalApi.Feature.OrganizationMachinesChanged.encode() + + message = build_message("organization_machines_changed", payload) + + with_mock FeatureProvider, + list_machines: fn _opts -> {:ok, []} end do + log = + capture_log(fn -> + result = Worker.handle_message(:default, message, %{}) + assert result == message + end) + + assert log =~ "invalidating machines for org #{org_id}" + assert_called(FeatureProvider.list_machines(reload: true, param: org_id)) + end + end + + test "features_changed invalidates global feature cache" do + message = build_message("features_changed") + + with_mock FeatureProvider, list_features: fn _opts -> {:ok, []} end do + log = + capture_log(fn -> + result = Worker.handle_message(:default, message, %{}) + assert result == message + end) + + assert log =~ "invalidating features" + assert_called(FeatureProvider.list_features(reload: true)) + end + end + + test "organization_features_changed invalidates org feature cache" do + org_id = UUID.uuid4() + + payload = + InternalApi.Feature.OrganizationFeaturesChanged.new(org_id: org_id) + |> InternalApi.Feature.OrganizationFeaturesChanged.encode() + + message = build_message("organization_features_changed", payload) + + with_mock FeatureProvider, + list_features: fn _opts -> {:ok, []} end do + log = + capture_log(fn -> + result = Worker.handle_message(:default, message, %{}) + assert result == message + end) + + assert log =~ "invalidating features for org #{org_id}" + assert_called(FeatureProvider.list_features(reload: true, param: org_id)) + end + end + + test "unknown routing key logs warning and returns message" do + message = build_message("unknown_key") + + log = + capture_log(fn -> + result = Worker.handle_message(:default, message, %{}) + assert result == message + end) + + assert log =~ "unknown routing key: unknown_key" + end + end +end