From 01a5b6aca3073809f1f6987b8d6abb18e6e01d5b Mon Sep 17 00:00:00 2001 From: Nelson Kopliku Date: Wed, 1 Apr 2026 19:05:41 +0200 Subject: [PATCH 1/8] Add ai user configuration migration and schema --- lib/trento/ai/user_configuration.ex | 58 +++++++++++++++++++ lib/trento/users/user.ex | 2 + ...60326100000_add_ai_user_configurations.exs | 14 +++++ 3 files changed, 74 insertions(+) create mode 100644 lib/trento/ai/user_configuration.ex create mode 100644 priv/repo/migrations/20260326100000_add_ai_user_configurations.exs diff --git a/lib/trento/ai/user_configuration.ex b/lib/trento/ai/user_configuration.ex new file mode 100644 index 0000000000..3655677a2a --- /dev/null +++ b/lib/trento/ai/user_configuration.ex @@ -0,0 +1,58 @@ +defmodule Trento.AI.UserConfiguration do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + alias Trento.Support.Ecto.EncryptedBinary + + alias Trento.AI.LlmRegistry + + @type t :: %__MODULE__{} + + @primary_key false + schema "ai_configurations" do + field :model, :string + field :provider, Ecto.Enum, values: LlmRegistry.providers() + field :api_key, EncryptedBinary, redact: true + + belongs_to :user, Trento.Users.User, primary_key: true + + timestamps(type: :utc_datetime_usec) + end + + def changeset(ai_configuration, attrs) do + updated_attrs = maybe_update_provider(attrs) + + ai_configuration + |> cast(updated_attrs, [:user_id, :model, :provider, :api_key]) + |> validate_required([:user_id, :model, :api_key]) + |> validate_change(:model, &validate_model/2) + |> unique_constraint(:user_id, + name: :ai_configurations_pkey, + message: "User already has a configuration" + ) + |> foreign_key_constraint(:user_id, message: "User does not exist") + end + + defp maybe_update_provider(attrs) do + case Map.has_key?(attrs, :model) do + true -> Map.put(attrs, :provider, get_model_provider(attrs)) + false -> attrs + end + end + + defp get_model_provider(attrs) do + attrs + |> Map.get(:model) + |> LlmRegistry.get_model_provider() + end + + defp validate_model(_model_field_atom, model) do + if LlmRegistry.model_supported?(model) do + [] + else + [model: {"is not supported", validation: :ai_model_validity}] + end + end +end diff --git a/lib/trento/users/user.ex b/lib/trento/users/user.ex index 8fd9a80fd6..b4570a8bd0 100644 --- a/lib/trento/users/user.ex +++ b/lib/trento/users/user.ex @@ -51,6 +51,8 @@ defmodule Trento.Users.User do has_many :personal_access_tokens, PersonalAccessToken, preload_order: [desc: :created_at] + has_one :ai_configuration, Trento.AI.UserConfiguration + timestamps(type: :utc_datetime_usec) end diff --git a/priv/repo/migrations/20260326100000_add_ai_user_configurations.exs b/priv/repo/migrations/20260326100000_add_ai_user_configurations.exs new file mode 100644 index 0000000000..dc6ad4c772 --- /dev/null +++ b/priv/repo/migrations/20260326100000_add_ai_user_configurations.exs @@ -0,0 +1,14 @@ +defmodule Trento.Repo.Migrations.AddAiUserConfigurationsTable do + use Ecto.Migration + + def change do + create table(:ai_configurations, primary_key: false) do + add :user_id, references(:users, on_delete: :delete_all), primary_key: true + add :provider, :string + add :model, :string + add :api_key, :binary + + timestamps(type: :utc_datetime_usec) + end + end +end From 99d17b02b61f77b604fcb6ecdaab322fde413992 Mon Sep 17 00:00:00 2001 From: Nelson Kopliku Date: Wed, 1 Apr 2026 19:06:27 +0200 Subject: [PATCH 2/8] Add llm registry --- lib/trento/ai/llm_registry.ex | 55 ++++++++++++++++ test/trento/ai/llm_registry_test.exs | 98 ++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 lib/trento/ai/llm_registry.ex create mode 100644 test/trento/ai/llm_registry_test.exs diff --git a/lib/trento/ai/llm_registry.ex b/lib/trento/ai/llm_registry.ex new file mode 100644 index 0000000000..5bc7659d48 --- /dev/null +++ b/lib/trento/ai/llm_registry.ex @@ -0,0 +1,55 @@ +defmodule Trento.AI.LlmRegistry do + @moduledoc """ + This module is responsible for managing the registry of available LLM providers and their models. + """ + + @doc """ + Returns the list of configured LLM providers. + """ + @spec providers :: [atom()] + def providers, do: Keyword.keys(get_ai_providers_config()) + + @doc """ + Returns the list of models for a given provider or all models if `:all` is passed. + """ + @spec get_provider_models(atom() | :all) :: [bitstring()] + def get_provider_models(:all), + do: + Enum.flat_map(get_ai_providers_config(), fn {_provider, config} -> + Keyword.get(config, :models, []) + end) + + def get_provider_models(provider) when is_atom(provider) do + get_ai_providers_config() + |> Keyword.get(provider, []) + |> Keyword.get(:models, []) + end + + def get_provider_models(_), do: [] + + @doc """ + Returns the provider for a given model or nil if the model is not supported. + """ + @spec get_model_provider(bitstring()) :: atom() | nil + def get_model_provider(model) do + Enum.find_value(get_ai_providers_config(), fn {provider, config} -> + if model in Keyword.get(config, :models, []) do + provider + else + nil + end + end) + end + + @doc """ + Checks if a given model is supported by any provider. + """ + @spec model_supported?(bitstring()) :: boolean() + def model_supported?(model), do: model in get_provider_models(:all) + + defp get_ai_providers_config do + :trento + |> Application.get_env(:ai, []) + |> Keyword.get(:providers, []) + end +end diff --git a/test/trento/ai/llm_registry_test.exs b/test/trento/ai/llm_registry_test.exs new file mode 100644 index 0000000000..0164c5f406 --- /dev/null +++ b/test/trento/ai/llm_registry_test.exs @@ -0,0 +1,98 @@ +defmodule Trento.AI.LlmRegistryTest do + use ExUnit.Case, async: true + + alias Trento.AI.LlmRegistry + + setup do + original_config = Application.get_env(:trento, :ai) + + Application.put_env(:trento, :ai, + enabled: true, + providers: [ + provider1: [ + models: [ + "model1", + "model2" + ] + ], + provider2: [ + models: [ + "model3", + "model4" + ] + ], + provider3: [ + models: [ + "model5", + "model6" + ] + ] + ] + ) + + on_exit(fn -> Application.put_env(:trento, :ai, original_config) end) + end + + describe "providers/0" do + test "returns the list of configured providers" do + assert LlmRegistry.providers() == [:provider1, :provider2, :provider3] + end + end + + describe "get_provider_models/1" do + test "returns the list of models for a given provider" do + assert LlmRegistry.get_provider_models(:provider1) == [ + "model1", + "model2" + ] + + assert LlmRegistry.get_provider_models(:provider2) == [ + "model3", + "model4" + ] + + assert LlmRegistry.get_provider_models(:provider3) == [ + "model5", + "model6" + ] + end + + test "returns an empty list for an unknown provider" do + assert LlmRegistry.get_provider_models(:unknown) == [] + assert LlmRegistry.get_provider_models("foo") == [] + end + + test "returns all available models" do + assert LlmRegistry.get_provider_models(:all) == [ + "model1", + "model2", + "model3", + "model4", + "model5", + "model6" + ] + end + end + + describe "get_model_provider/1" do + test "returns the provider for a given model" do + assert LlmRegistry.get_model_provider("model6") == :provider3 + assert LlmRegistry.get_model_provider("model1") == :provider1 + end + + test "returns nil for an unknown model" do + assert LlmRegistry.get_model_provider("unknown-model") == nil + end + end + + describe "model_supported?/1" do + test "returns true for a supported model" do + assert LlmRegistry.model_supported?("model1") == true + assert LlmRegistry.model_supported?("model6") == true + end + + test "returns false for an unsupported model" do + assert LlmRegistry.model_supported?("unknown-model") == false + end + end +end From 73a6bb5a0d8ea126a50446275c64aabd018b8036 Mon Sep 17 00:00:00 2001 From: Nelson Kopliku Date: Wed, 1 Apr 2026 19:07:33 +0200 Subject: [PATCH 3/8] Add create/update configuration functions --- lib/trento/ai/configurations.ex | 58 ++++ test/support/factory.ex | 21 ++ test/trento/ai/configurations_test.exs | 401 +++++++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 lib/trento/ai/configurations.ex create mode 100644 test/trento/ai/configurations_test.exs diff --git a/lib/trento/ai/configurations.ex b/lib/trento/ai/configurations.ex new file mode 100644 index 0000000000..edab4d3083 --- /dev/null +++ b/lib/trento/ai/configurations.ex @@ -0,0 +1,58 @@ +defmodule Trento.AI.Configurations do + @moduledoc """ + This module is responsible for managing user AI configurations. + """ + + alias Trento.Users.User + + alias Trento.AI.UserConfiguration + + alias Trento.Repo + + @doc """ + Creates a user configuration for AI. + + Only eligible users (not deleted or locked) can have an AI configuration. + """ + @spec create_user_configuration(User.t(), map()) :: + {:ok, UserConfiguration.t()} | {:error, Ecto.Changeset.t()} | {:error, :forbidden} + def create_user_configuration( + %User{id: user_id, deleted_at: nil, locked_at: nil}, + attrs + ) do + %UserConfiguration{} + |> UserConfiguration.changeset(Map.put(attrs, :user_id, user_id)) + |> Repo.insert() + end + + def create_user_configuration(%User{}, _), do: {:error, :forbidden} + + @doc """ + Updates a user configuration for AI. + + Only eligible users (not deleted or locked) can update their AI configuration. + + If the user does not have an existing configuration, an error will be returned. + """ + @spec update_user_configuration(User.t(), map()) :: + {:ok, UserConfiguration.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :forbidden | :not_found} + def update_user_configuration( + %User{id: user_id, deleted_at: nil, locked_at: nil}, + attrs + ) + when not is_nil(user_id) do + case Repo.get_by(UserConfiguration, user_id: user_id) do + nil -> + {:error, :not_found} + + %UserConfiguration{} = user_configuration -> + user_configuration + |> UserConfiguration.changeset(attrs) + |> Repo.update() + end + end + + def update_user_configuration(%User{}, _), do: {:error, :forbidden} +end diff --git a/test/support/factory.ex b/test/support/factory.ex index c0adbe6a5c..b2df41c64e 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -182,6 +182,8 @@ defmodule Trento.Factory do alias TrentoWeb.Auth.PersonalAccessToken, as: PAT + alias Trento.AI.{LlmRegistry, UserConfiguration} + use ExMachina.Ecto, repo: Trento.Repo def host_registered_event_factory do @@ -1458,4 +1460,23 @@ defmodule Trento.Factory do target_type: Enum.random(["host", "cluster"]) } end + + def random_ai_model do + :all + |> LlmRegistry.get_provider_models() + |> Enum.random() + end + + def ai_user_configuration_factory(attrs) do + user_id = Map.get(attrs, :user_id, 1) + model = Map.get(attrs, :model, random_ai_model()) + + %UserConfiguration{} + |> UserConfiguration.changeset(%{ + user_id: user_id, + model: model, + api_key: Faker.String.base64(32) + }) + |> Ecto.Changeset.apply_changes() + end end diff --git a/test/trento/ai/configurations_test.exs b/test/trento/ai/configurations_test.exs new file mode 100644 index 0000000000..711c344492 --- /dev/null +++ b/test/trento/ai/configurations_test.exs @@ -0,0 +1,401 @@ +defmodule Trento.Ai.ConfigurationsTest do + use Trento.DataCase, async: true + + alias Trento.Users.User + + alias Trento.AI.{Configurations, LlmRegistry, UserConfiguration} + + alias Trento.Factory + + import Trento.Factory + + describe "creating a user AI configuration" do + test "should not allow creating AI configuration for a deleted or disabled user" do + deleted_user = insert(:user, deleted_at: Faker.DateTime.backward(3)) + disabled_user = insert(:user, locked_at: Faker.DateTime.backward(3)) + + for user <- [deleted_user, disabled_user] do + assert {:error, :forbidden} == + Configurations.create_user_configuration( + user, + %{ + model: Factory.random_ai_model(), + api_key: Faker.String.base64() + } + ) + end + end + + test "should not allow creating AI configuration for a user without identifier" do + user = %User{} + + assert {:error, + %Ecto.Changeset{errors: [user_id: {"can't be blank", [validation: :required]}]}} = + Configurations.create_user_configuration( + user, + %{ + model: Factory.random_ai_model(), + api_key: Faker.String.base64() + } + ) + end + + test "should not allow creating AI configuration for a non existent user" do + user = %User{id: 124} + + assert {:error, %Ecto.Changeset{errors: [user_id: {"User does not exist", _}]}} = + Configurations.create_user_configuration( + user, + %{ + model: Factory.random_ai_model(), + api_key: Faker.String.base64() + } + ) + end + + failing_validation_scenarios = [ + %{ + name: "empty attributes", + attrs: %{}, + expected_errors: [ + model: {"can't be blank", [validation: :required]}, + api_key: {"can't be blank", [validation: :required]} + ] + }, + %{ + name: "missing model", + attrs: %{ + api_key: Faker.String.base64() + }, + expected_errors: [ + model: {"can't be blank", [validation: :required]} + ] + }, + %{ + name: "empty model", + attrs_sceanarios: + Enum.map( + [nil, "", " "], + &%{ + model: &1, + api_key: Faker.String.base64() + } + ), + expected_errors: [ + model: {"can't be blank", [validation: :required]} + ] + }, + %{ + name: "non string model", + attrs_sceanarios: + Enum.map( + [123, true, %{}, []], + &%{ + model: &1, + api_key: Faker.String.base64() + } + ), + expected_errors: [ + model: {"is invalid", [type: :string, validation: :cast]} + ] + }, + %{ + name: "unsupported model", + attrs: %{ + model: Faker.Lorem.word(), + api_key: Faker.String.base64() + }, + expected_errors: [ + model: {"is not supported", [validation: :ai_model_validity]} + ] + }, + %{ + name: "missing api key", + attrs: %{ + model: Factory.random_ai_model() + }, + expected_errors: [ + api_key: {"can't be blank", [validation: :required]} + ] + }, + %{ + name: "empty api key", + attrs_sceanarios: + Enum.map( + [nil, "", " "], + &%{ + model: Factory.random_ai_model(), + api_key: &1 + } + ), + expected_errors: [ + api_key: {"can't be blank", [validation: :required]} + ] + }, + %{ + name: "non string api key", + attrs_sceanarios: + Enum.map( + [123, true, %{}, []], + &%{ + model: Factory.random_ai_model(), + api_key: &1 + } + ), + expected_errors: [ + api_key: {"is invalid", [type: Trento.Support.Ecto.EncryptedBinary, validation: :cast]} + ] + } + ] + + for %{name: name} = failing_validation_scenario <- failing_validation_scenarios do + @failing_validation_scenario failing_validation_scenario + + test "should not allow creating AI configuration with invalid data - #{name}" do + %User{id: user_id} = user = insert(:user) + + %{expected_errors: expected_errors} = @failing_validation_scenario + + attrs = Map.get(@failing_validation_scenario, :attrs, %{}) + attrs_scenarios = Map.get(@failing_validation_scenario, :attrs_sceanarios, [attrs]) + + for resolved_scenario <- attrs_scenarios do + assert {:error, %Ecto.Changeset{errors: ^expected_errors}} = + Configurations.create_user_configuration(user, resolved_scenario) + + assert nil == load_ai_config(user_id) + end + end + end + + test "should not allow creating AI configuration for a user that already has one" do + %User{id: user_id} = user = insert(:user) + + insert(:ai_user_configuration, user_id: user_id) + + assert {:error, + %Ecto.Changeset{ + errors: [ + user_id: {"User already has a configuration", _} + ] + }} = + Configurations.create_user_configuration( + user, + %{ + model: random_ai_model(), + api_key: Faker.String.base64() + } + ) + end + + test "should allow creating AI configuration with valid data" do + %User{id: user_id} = user = insert(:user) + + model = Factory.random_ai_model() + api_key = Faker.String.base64() + + expected_provider = LlmRegistry.get_model_provider(model) + + assert {:ok, + %UserConfiguration{ + model: ^model, + provider: ^expected_provider, + api_key: ^api_key, + user_id: ^user_id + } = created_config} = + Configurations.create_user_configuration(user, %{model: model, api_key: api_key}) + + assert ^created_config = load_ai_config(user_id) + end + end + + describe "updating a user AI configuration" do + test "should not allow updating AI configuration for a deleted or disabled user" do + %User{id: deleted_user_id} = + deleted_user = insert(:user, deleted_at: Faker.DateTime.backward(3)) + + %User{id: disabled_user_id} = + disabled_user = insert(:user, locked_at: Faker.DateTime.backward(3)) + + insert(:ai_user_configuration, user_id: deleted_user_id) + insert(:ai_user_configuration, user_id: disabled_user_id) + + for %User{id: user_id} = user <- [deleted_user, disabled_user] do + assert {:error, :forbidden} == + Configurations.update_user_configuration( + user, + %{model: Factory.random_ai_model(), api_key: Faker.String.base64()} + ) + + assert %UserConfiguration{} = load_ai_config(user_id) + end + end + + test "should not allow updating AI configuration for a user without identifier" do + user = %User{} + + assert {:error, :forbidden} = + Configurations.update_user_configuration( + user, + %{model: Factory.random_ai_model(), api_key: Faker.String.base64()} + ) + end + + test "should not allow updating AI configuration for a non existent user" do + user = %User{id: 124} + + assert {:error, :not_found} = + Configurations.update_user_configuration( + user, + %{model: Factory.random_ai_model(), api_key: Faker.String.base64()} + ) + end + + test "should not allow updating AI configuration for a user that does not have one" do + %User{} = user = insert(:user) + + assert {:error, :not_found} = + Configurations.update_user_configuration( + user, + %{model: Factory.random_ai_model(), api_key: Faker.String.base64()} + ) + end + + failing_update_validation_scenarios = [ + %{ + name: "empty model", + attrs_sceanarios: Enum.map([nil, "", " "], &%{model: &1}), + expected_errors: [ + model: {"can't be blank", [validation: :required]} + ] + }, + %{ + name: "non string model", + attrs_sceanarios: Enum.map([123, true, %{}, []], &%{model: &1}), + expected_errors: [ + model: {"is invalid", [type: :string, validation: :cast]} + ] + }, + %{ + name: "unsupported model", + attrs: %{ + model: Faker.Lorem.word() + }, + expected_errors: [ + model: {"is not supported", [validation: :ai_model_validity]} + ] + }, + %{ + name: "empty api key", + attrs_sceanarios: Enum.map([nil, "", " "], &%{api_key: &1}), + expected_errors: [ + api_key: {"can't be blank", [validation: :required]} + ] + }, + %{ + name: "non string api key", + attrs_sceanarios: Enum.map([123, true, %{}, []], &%{api_key: &1}), + expected_errors: [ + api_key: {"is invalid", [type: Trento.Support.Ecto.EncryptedBinary, validation: :cast]} + ] + }, + %{ + name: "combined invalid model and api key", + attrs: %{ + model: Faker.Lorem.word(), + api_key: 32 + }, + expected_errors: [ + model: {"is not supported", [validation: :ai_model_validity]}, + api_key: {"is invalid", [type: Trento.Support.Ecto.EncryptedBinary, validation: :cast]} + ] + } + ] + + for %{name: name} = failing_update_validation_scenario <- failing_update_validation_scenarios do + @failing_update_validation_scenario failing_update_validation_scenario + + test "should not allow updating AI configuration with invalid data - #{name}" do + %User{id: user_id} = user = insert(:user) + + %UserConfiguration{ + model: initial_model, + provider: initial_provider, + api_key: initial_api_key + } = insert(:ai_user_configuration, user_id: user_id) + + %{expected_errors: expected_errors} = @failing_update_validation_scenario + + attrs = Map.get(@failing_update_validation_scenario, :attrs, %{}) + attrs_scenarios = Map.get(@failing_update_validation_scenario, :attrs_sceanarios, [attrs]) + + for resolved_scenario <- attrs_scenarios do + assert {:error, %Ecto.Changeset{errors: ^expected_errors}} = + Configurations.update_user_configuration(user, resolved_scenario) + + assert %UserConfiguration{ + user_id: ^user_id, + model: ^initial_model, + provider: ^initial_provider, + api_key: ^initial_api_key + } = load_ai_config(user_id) + end + end + end + + test "should allow updating only the model" do + %User{id: user_id} = user = insert(:user) + + %UserConfiguration{ + model: _initial_model, + provider: _initial_provider, + api_key: initial_api_key + } = insert(:ai_user_configuration, user_id: user_id) + + new_model = Factory.random_ai_model() + + expected_provider = LlmRegistry.get_model_provider(new_model) + + assert {:ok, + %UserConfiguration{ + model: ^new_model, + provider: ^expected_provider, + api_key: ^initial_api_key, + user_id: ^user_id + } = updated_config} = + Configurations.update_user_configuration(user, %{ + model: new_model + }) + + assert ^updated_config = load_ai_config(user_id) + end + + test "should allow updating AI configuration with valid data" do + %User{id: user_id} = user = insert(:user) + + insert(:ai_user_configuration, user_id: user_id) + + new_model = Factory.random_ai_model() + new_api_key = Faker.String.base64() + + expected_provider = LlmRegistry.get_model_provider(new_model) + + assert {:ok, + %UserConfiguration{ + model: ^new_model, + provider: ^expected_provider, + api_key: ^new_api_key, + user_id: ^user_id + } = updated_config} = + Configurations.update_user_configuration(user, %{ + model: new_model, + api_key: new_api_key + }) + + assert ^updated_config = load_ai_config(user_id) + end + end + + defp load_ai_config(user_id), + do: Trento.Repo.get_by(UserConfiguration, user_id: user_id) +end From ed5d50cf3f590140957b9cabe5b74dff07f568ea Mon Sep 17 00:00:00 2001 From: Nelson Kopliku Date: Wed, 1 Apr 2026 19:08:02 +0200 Subject: [PATCH 4/8] Add entrypoint AI context module --- lib/trento/ai.ex | 49 ++++++++++++++++++++++++++++++++++++ test/trento/ai_test.exs | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 lib/trento/ai.ex create mode 100644 test/trento/ai_test.exs diff --git a/lib/trento/ai.ex b/lib/trento/ai.ex new file mode 100644 index 0000000000..527489186a --- /dev/null +++ b/lib/trento/ai.ex @@ -0,0 +1,49 @@ +defmodule Trento.AI do + @moduledoc """ + The `Trento.AI` module provides functions to interact with the AI features of the Trento application. + """ + + alias Trento.AI.Configurations + + @doc """ + Checks if the AI features are enabled. + """ + @spec enabled?() :: boolean() + def enabled?, + do: + :trento + |> Application.get_env(:ai, []) + |> Keyword.get(:enabled, false) + + @doc """ + Creates a user configuration for AI. + + See `Trento.AI.Configurations.create_user_configuration/2` for more details. + """ + def create_user_configuration(user, attrs) do + execute_if_enabled(fn -> configurations().create_user_configuration(user, attrs) end) + end + + @doc """ + Updates a user configuration for AI. + + See `Trento.AI.Configurations.update_user_configuration/2` for more details. + """ + def update_user_configuration(user, attrs) do + execute_if_enabled(fn -> configurations().update_user_configuration(user, attrs) end) + end + + defp execute_if_enabled(function) do + if enabled?() do + function.() + else + {:error, :ai_features_disabled} + end + end + + defp configurations do + :trento + |> Application.get_env(:ai) + |> Keyword.get(:configurations, Configurations) + end +end diff --git a/test/trento/ai_test.exs b/test/trento/ai_test.exs new file mode 100644 index 0000000000..65d413cc6f --- /dev/null +++ b/test/trento/ai_test.exs @@ -0,0 +1,56 @@ +defmodule Trento.AiTest do + use ExUnit.Case + + alias Trento.AI + + setup do + original_config = Application.get_env(:trento, :ai) + + on_exit(fn -> Application.put_env(:trento, :ai, original_config) end) + end + + describe "enabled?/0" do + test "returns true when AI features are enabled" do + Application.put_env(:trento, :ai, enabled: true) + assert AI.enabled?() == true + end + + test "returns false when AI features are disabled" do + Application.put_env(:trento, :ai, enabled: false) + assert AI.enabled?() == false + end + + test "returns false when AI features are not configured" do + Application.delete_env(:trento, :ai) + assert AI.enabled?() == false + end + end + + describe "enabling/disabling features" do + test "features are disabled" do + Application.put_env(:trento, :ai, enabled: false) + assert AI.create_user_configuration(%{}, %{}) == {:error, :ai_features_disabled} + assert AI.update_user_configuration(%{}, %{}) == {:error, :ai_features_disabled} + end + + test "features are enabled" do + Application.put_env(:trento, :ai, + enabled: true, + configurations: DummyConfigurations + ) + + assert AI.create_user_configuration(%{}, %{}) == {:ok, :created} + assert AI.update_user_configuration(%{}, %{}) == {:ok, :updated} + end + end +end + +defmodule DummyConfigurations do + def create_user_configuration(_user, _attrs) do + {:ok, :created} + end + + def update_user_configuration(_user, _attrs) do + {:ok, :updated} + end +end From cae177620949a0d5eb2e40e8e7072b0684356e4f Mon Sep 17 00:00:00 2001 From: Nelson Kopliku Date: Wed, 1 Apr 2026 19:13:37 +0200 Subject: [PATCH 5/8] Improve AI test arguments --- test/trento/ai_test.exs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/test/trento/ai_test.exs b/test/trento/ai_test.exs index 65d413cc6f..f8a3c54c12 100644 --- a/test/trento/ai_test.exs +++ b/test/trento/ai_test.exs @@ -3,6 +3,8 @@ defmodule Trento.AiTest do alias Trento.AI + import Trento.Factory + setup do original_config = Application.get_env(:trento, :ai) @@ -29,8 +31,15 @@ defmodule Trento.AiTest do describe "enabling/disabling features" do test "features are disabled" do Application.put_env(:trento, :ai, enabled: false) - assert AI.create_user_configuration(%{}, %{}) == {:error, :ai_features_disabled} - assert AI.update_user_configuration(%{}, %{}) == {:error, :ai_features_disabled} + user = build(:user) + + attrs = %{ + model: "foo", + api_key: "bar" + } + + assert AI.create_user_configuration(user, attrs) == {:error, :ai_features_disabled} + assert AI.update_user_configuration(user, attrs) == {:error, :ai_features_disabled} end test "features are enabled" do @@ -39,8 +48,15 @@ defmodule Trento.AiTest do configurations: DummyConfigurations ) - assert AI.create_user_configuration(%{}, %{}) == {:ok, :created} - assert AI.update_user_configuration(%{}, %{}) == {:ok, :updated} + user = build(:user) + + attrs = %{ + model: "foo", + api_key: "bar" + } + + assert AI.create_user_configuration(user, attrs) == {:ok, :created} + assert AI.update_user_configuration(user, attrs) == {:ok, :updated} end end end From 1d2f6a9bda98fb4c34c20ea2dc3fc4d8f863fdf9 Mon Sep 17 00:00:00 2001 From: Nelson Kopliku Date: Thu, 2 Apr 2026 10:28:08 +0200 Subject: [PATCH 6/8] Improve naming consistency --- lib/trento/ai/llm_registry.ex | 2 +- lib/trento/ai/user_configuration.ex | 8 +++---- test/support/factory.ex | 4 ++-- test/trento/ai/configurations_test.exs | 8 +++---- test/trento/ai/llm_registry_test.exs | 30 +++++++++++++------------- test/trento/ai_test.exs | 2 +- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lib/trento/ai/llm_registry.ex b/lib/trento/ai/llm_registry.ex index 5bc7659d48..40695d44b8 100644 --- a/lib/trento/ai/llm_registry.ex +++ b/lib/trento/ai/llm_registry.ex @@ -1,4 +1,4 @@ -defmodule Trento.AI.LlmRegistry do +defmodule Trento.AI.LLMRegistry do @moduledoc """ This module is responsible for managing the registry of available LLM providers and their models. """ diff --git a/lib/trento/ai/user_configuration.ex b/lib/trento/ai/user_configuration.ex index 3655677a2a..0b62e41b8b 100644 --- a/lib/trento/ai/user_configuration.ex +++ b/lib/trento/ai/user_configuration.ex @@ -6,14 +6,14 @@ defmodule Trento.AI.UserConfiguration do alias Trento.Support.Ecto.EncryptedBinary - alias Trento.AI.LlmRegistry + alias Trento.AI.LLMRegistry @type t :: %__MODULE__{} @primary_key false schema "ai_configurations" do field :model, :string - field :provider, Ecto.Enum, values: LlmRegistry.providers() + field :provider, Ecto.Enum, values: LLMRegistry.providers() field :api_key, EncryptedBinary, redact: true belongs_to :user, Trento.Users.User, primary_key: true @@ -45,11 +45,11 @@ defmodule Trento.AI.UserConfiguration do defp get_model_provider(attrs) do attrs |> Map.get(:model) - |> LlmRegistry.get_model_provider() + |> LLMRegistry.get_model_provider() end defp validate_model(_model_field_atom, model) do - if LlmRegistry.model_supported?(model) do + if LLMRegistry.model_supported?(model) do [] else [model: {"is not supported", validation: :ai_model_validity}] diff --git a/test/support/factory.ex b/test/support/factory.ex index b2df41c64e..acb6c27a1f 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -182,7 +182,7 @@ defmodule Trento.Factory do alias TrentoWeb.Auth.PersonalAccessToken, as: PAT - alias Trento.AI.{LlmRegistry, UserConfiguration} + alias Trento.AI.{LLMRegistry, UserConfiguration} use ExMachina.Ecto, repo: Trento.Repo @@ -1463,7 +1463,7 @@ defmodule Trento.Factory do def random_ai_model do :all - |> LlmRegistry.get_provider_models() + |> LLMRegistry.get_provider_models() |> Enum.random() end diff --git a/test/trento/ai/configurations_test.exs b/test/trento/ai/configurations_test.exs index 711c344492..570875f5d6 100644 --- a/test/trento/ai/configurations_test.exs +++ b/test/trento/ai/configurations_test.exs @@ -3,7 +3,7 @@ defmodule Trento.Ai.ConfigurationsTest do alias Trento.Users.User - alias Trento.AI.{Configurations, LlmRegistry, UserConfiguration} + alias Trento.AI.{Configurations, LLMRegistry, UserConfiguration} alias Trento.Factory @@ -194,7 +194,7 @@ defmodule Trento.Ai.ConfigurationsTest do model = Factory.random_ai_model() api_key = Faker.String.base64() - expected_provider = LlmRegistry.get_model_provider(model) + expected_provider = LLMRegistry.get_model_provider(model) assert {:ok, %UserConfiguration{ @@ -354,7 +354,7 @@ defmodule Trento.Ai.ConfigurationsTest do new_model = Factory.random_ai_model() - expected_provider = LlmRegistry.get_model_provider(new_model) + expected_provider = LLMRegistry.get_model_provider(new_model) assert {:ok, %UserConfiguration{ @@ -378,7 +378,7 @@ defmodule Trento.Ai.ConfigurationsTest do new_model = Factory.random_ai_model() new_api_key = Faker.String.base64() - expected_provider = LlmRegistry.get_model_provider(new_model) + expected_provider = LLMRegistry.get_model_provider(new_model) assert {:ok, %UserConfiguration{ diff --git a/test/trento/ai/llm_registry_test.exs b/test/trento/ai/llm_registry_test.exs index 0164c5f406..57c98211d2 100644 --- a/test/trento/ai/llm_registry_test.exs +++ b/test/trento/ai/llm_registry_test.exs @@ -1,7 +1,7 @@ -defmodule Trento.AI.LlmRegistryTest do +defmodule Trento.AI.LLMRegistryTest do use ExUnit.Case, async: true - alias Trento.AI.LlmRegistry + alias Trento.AI.LLMRegistry setup do original_config = Application.get_env(:trento, :ai) @@ -35,35 +35,35 @@ defmodule Trento.AI.LlmRegistryTest do describe "providers/0" do test "returns the list of configured providers" do - assert LlmRegistry.providers() == [:provider1, :provider2, :provider3] + assert LLMRegistry.providers() == [:provider1, :provider2, :provider3] end end describe "get_provider_models/1" do test "returns the list of models for a given provider" do - assert LlmRegistry.get_provider_models(:provider1) == [ + assert LLMRegistry.get_provider_models(:provider1) == [ "model1", "model2" ] - assert LlmRegistry.get_provider_models(:provider2) == [ + assert LLMRegistry.get_provider_models(:provider2) == [ "model3", "model4" ] - assert LlmRegistry.get_provider_models(:provider3) == [ + assert LLMRegistry.get_provider_models(:provider3) == [ "model5", "model6" ] end test "returns an empty list for an unknown provider" do - assert LlmRegistry.get_provider_models(:unknown) == [] - assert LlmRegistry.get_provider_models("foo") == [] + assert LLMRegistry.get_provider_models(:unknown) == [] + assert LLMRegistry.get_provider_models("foo") == [] end test "returns all available models" do - assert LlmRegistry.get_provider_models(:all) == [ + assert LLMRegistry.get_provider_models(:all) == [ "model1", "model2", "model3", @@ -76,23 +76,23 @@ defmodule Trento.AI.LlmRegistryTest do describe "get_model_provider/1" do test "returns the provider for a given model" do - assert LlmRegistry.get_model_provider("model6") == :provider3 - assert LlmRegistry.get_model_provider("model1") == :provider1 + assert LLMRegistry.get_model_provider("model6") == :provider3 + assert LLMRegistry.get_model_provider("model1") == :provider1 end test "returns nil for an unknown model" do - assert LlmRegistry.get_model_provider("unknown-model") == nil + assert LLMRegistry.get_model_provider("unknown-model") == nil end end describe "model_supported?/1" do test "returns true for a supported model" do - assert LlmRegistry.model_supported?("model1") == true - assert LlmRegistry.model_supported?("model6") == true + assert LLMRegistry.model_supported?("model1") == true + assert LLMRegistry.model_supported?("model6") == true end test "returns false for an unsupported model" do - assert LlmRegistry.model_supported?("unknown-model") == false + assert LLMRegistry.model_supported?("unknown-model") == false end end end diff --git a/test/trento/ai_test.exs b/test/trento/ai_test.exs index f8a3c54c12..71f7e2cbfb 100644 --- a/test/trento/ai_test.exs +++ b/test/trento/ai_test.exs @@ -1,4 +1,4 @@ -defmodule Trento.AiTest do +defmodule Trento.AITest do use ExUnit.Case alias Trento.AI From 50d20f2838196aafb23be5ad9a72fe80a2b45b36 Mon Sep 17 00:00:00 2001 From: Nelson Kopliku Date: Thu, 2 Apr 2026 13:40:35 +0200 Subject: [PATCH 7/8] Abstract away access to application env global state to avoid test flakiness --- lib/trento/ai.ex | 14 ++--- lib/trento/ai/application_config_loader.ex | 21 +++++++ lib/trento/ai/llm_registry.ex | 8 +-- test/support/ai/ai_case.ex | 18 ++++++ test/support/factory.ex | 2 + test/test_helper.exs | 13 +++++ test/trento/ai/configurations_test.exs | 1 + test/trento/ai/llm_registry_test.exs | 68 ++++++++++++++-------- test/trento/ai_test.exs | 25 ++++---- 9 files changed, 119 insertions(+), 51 deletions(-) create mode 100644 lib/trento/ai/application_config_loader.ex create mode 100644 test/support/ai/ai_case.ex diff --git a/lib/trento/ai.ex b/lib/trento/ai.ex index 527489186a..4238733bc0 100644 --- a/lib/trento/ai.ex +++ b/lib/trento/ai.ex @@ -3,6 +3,8 @@ defmodule Trento.AI do The `Trento.AI` module provides functions to interact with the AI features of the Trento application. """ + alias Trento.AI.ApplicationConfigLoader + alias Trento.AI.Configurations @doc """ @@ -10,10 +12,7 @@ defmodule Trento.AI do """ @spec enabled?() :: boolean() def enabled?, - do: - :trento - |> Application.get_env(:ai, []) - |> Keyword.get(:enabled, false) + do: Keyword.get(ApplicationConfigLoader.load(), :enabled, false) @doc """ Creates a user configuration for AI. @@ -41,9 +40,6 @@ defmodule Trento.AI do end end - defp configurations do - :trento - |> Application.get_env(:ai) - |> Keyword.get(:configurations, Configurations) - end + defp configurations, + do: Keyword.get(ApplicationConfigLoader.load(), :configurations, Configurations) end diff --git a/lib/trento/ai/application_config_loader.ex b/lib/trento/ai/application_config_loader.ex new file mode 100644 index 0000000000..f707e53530 --- /dev/null +++ b/lib/trento/ai/application_config_loader.ex @@ -0,0 +1,21 @@ +defmodule Trento.AI.ApplicationConfigLoader do + @moduledoc """ + This module is responsible for loading the AI application configuration and providing access to it. + """ + + @behaviour Trento.AI.ApplicationConfigLoader + + @callback load_config :: keyword() + + @impl true + def load_config, do: Application.get_env(:trento, :ai, []) + + @spec load :: keyword() + def load, do: impl().load_config() + + defp impl, + do: + :trento + |> Application.get_env(:ai, []) + |> Keyword.get(:application_config_loader, __MODULE__) +end diff --git a/lib/trento/ai/llm_registry.ex b/lib/trento/ai/llm_registry.ex index 40695d44b8..ff18510f39 100644 --- a/lib/trento/ai/llm_registry.ex +++ b/lib/trento/ai/llm_registry.ex @@ -3,6 +3,8 @@ defmodule Trento.AI.LLMRegistry do This module is responsible for managing the registry of available LLM providers and their models. """ + alias Trento.AI.ApplicationConfigLoader + @doc """ Returns the list of configured LLM providers. """ @@ -47,9 +49,5 @@ defmodule Trento.AI.LLMRegistry do @spec model_supported?(bitstring()) :: boolean() def model_supported?(model), do: model in get_provider_models(:all) - defp get_ai_providers_config do - :trento - |> Application.get_env(:ai, []) - |> Keyword.get(:providers, []) - end + defp get_ai_providers_config, do: Keyword.get(ApplicationConfigLoader.load(), :providers, []) end diff --git a/test/support/ai/ai_case.ex b/test/support/ai/ai_case.ex new file mode 100644 index 0000000000..0a1d7aeb37 --- /dev/null +++ b/test/support/ai/ai_case.ex @@ -0,0 +1,18 @@ +defmodule Trento.AI.AICase do + @moduledoc false + + use ExUnit.CaseTemplate + + setup _ do + stub_config_loader() + + :ok + end + + def stub_config_loader do + Mox.stub_with( + Trento.AI.ApplicationConfigLoader.Mock, + Trento.AI.ApplicationConfigLoader + ) + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index acb6c27a1f..8c059a5d7f 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -1462,6 +1462,8 @@ defmodule Trento.Factory do end def random_ai_model do + Trento.AI.AICase.stub_config_loader() + :all |> LLMRegistry.get_provider_models() |> Enum.random() diff --git a/test/test_helper.exs b/test/test_helper.exs index 3b9bc4ca5a..f940eb1823 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -53,6 +53,19 @@ Mox.defmock(Trento.Support.DateService.Mock, for: Trento.Support.DateService) Mox.defmock(Joken.CurrentTime.Mock, for: Joken.CurrentTime) Application.put_env(:joken, :current_time_adapter, Joken.CurrentTime.Mock) +Mox.defmock(Trento.AI.ApplicationConfigLoader.Mock, + for: Trento.AI.ApplicationConfigLoader +) + +test_ai_config = + Keyword.put( + Application.get_env(:trento, :ai), + :application_config_loader, + Trento.AI.ApplicationConfigLoader.Mock + ) + +Application.put_env(:trento, :ai, test_ai_config) + Application.ensure_all_started(:ex_machina, :faker) if Application.get_env(:trento, :flaky_tests_detection)[:enabled?] == true do diff --git a/test/trento/ai/configurations_test.exs b/test/trento/ai/configurations_test.exs index 570875f5d6..12c7ed3117 100644 --- a/test/trento/ai/configurations_test.exs +++ b/test/trento/ai/configurations_test.exs @@ -1,5 +1,6 @@ defmodule Trento.Ai.ConfigurationsTest do use Trento.DataCase, async: true + use Trento.AI.AICase alias Trento.Users.User diff --git a/test/trento/ai/llm_registry_test.exs b/test/trento/ai/llm_registry_test.exs index 57c98211d2..555096bd5b 100644 --- a/test/trento/ai/llm_registry_test.exs +++ b/test/trento/ai/llm_registry_test.exs @@ -3,44 +3,50 @@ defmodule Trento.AI.LLMRegistryTest do alias Trento.AI.LLMRegistry - setup do - original_config = Application.get_env(:trento, :ai) - - Application.put_env(:trento, :ai, - enabled: true, - providers: [ - provider1: [ - models: [ - "model1", - "model2" - ] - ], - provider2: [ - models: [ - "model3", - "model4" - ] - ], - provider3: [ - models: [ - "model5", - "model6" + import Mox + + setup :verify_on_exit! + + defp expect_config_loader_to_be_called_times(times) do + expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, times, fn -> + [ + enabled: true, + providers: [ + provider1: [ + models: [ + "model1", + "model2" + ] + ], + provider2: [ + models: [ + "model3", + "model4" + ] + ], + provider3: [ + models: [ + "model5", + "model6" + ] ] ] ] - ) - - on_exit(fn -> Application.put_env(:trento, :ai, original_config) end) + end) end describe "providers/0" do test "returns the list of configured providers" do + expect_config_loader_to_be_called_times(1) + assert LLMRegistry.providers() == [:provider1, :provider2, :provider3] end end describe "get_provider_models/1" do test "returns the list of models for a given provider" do + expect_config_loader_to_be_called_times(3) + assert LLMRegistry.get_provider_models(:provider1) == [ "model1", "model2" @@ -58,11 +64,15 @@ defmodule Trento.AI.LLMRegistryTest do end test "returns an empty list for an unknown provider" do + expect_config_loader_to_be_called_times(1) + assert LLMRegistry.get_provider_models(:unknown) == [] assert LLMRegistry.get_provider_models("foo") == [] end test "returns all available models" do + expect_config_loader_to_be_called_times(1) + assert LLMRegistry.get_provider_models(:all) == [ "model1", "model2", @@ -76,22 +86,30 @@ defmodule Trento.AI.LLMRegistryTest do describe "get_model_provider/1" do test "returns the provider for a given model" do + expect_config_loader_to_be_called_times(2) + assert LLMRegistry.get_model_provider("model6") == :provider3 assert LLMRegistry.get_model_provider("model1") == :provider1 end test "returns nil for an unknown model" do + expect_config_loader_to_be_called_times(1) + assert LLMRegistry.get_model_provider("unknown-model") == nil end end describe "model_supported?/1" do test "returns true for a supported model" do + expect_config_loader_to_be_called_times(2) + assert LLMRegistry.model_supported?("model1") == true assert LLMRegistry.model_supported?("model6") == true end test "returns false for an unsupported model" do + expect_config_loader_to_be_called_times(1) + assert LLMRegistry.model_supported?("unknown-model") == false end end diff --git a/test/trento/ai_test.exs b/test/trento/ai_test.exs index 71f7e2cbfb..5ea95e284b 100644 --- a/test/trento/ai_test.exs +++ b/test/trento/ai_test.exs @@ -5,32 +5,31 @@ defmodule Trento.AITest do import Trento.Factory - setup do - original_config = Application.get_env(:trento, :ai) + import Mox - on_exit(fn -> Application.put_env(:trento, :ai, original_config) end) - end + setup :verify_on_exit! describe "enabled?/0" do test "returns true when AI features are enabled" do - Application.put_env(:trento, :ai, enabled: true) + expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, fn -> [enabled: true] end) + assert AI.enabled?() == true end test "returns false when AI features are disabled" do - Application.put_env(:trento, :ai, enabled: false) + expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, fn -> [enabled: false] end) assert AI.enabled?() == false end test "returns false when AI features are not configured" do - Application.delete_env(:trento, :ai) + expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, fn -> [] end) assert AI.enabled?() == false end end describe "enabling/disabling features" do test "features are disabled" do - Application.put_env(:trento, :ai, enabled: false) + expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, 2, fn -> [enabled: false] end) user = build(:user) attrs = %{ @@ -43,10 +42,12 @@ defmodule Trento.AITest do end test "features are enabled" do - Application.put_env(:trento, :ai, - enabled: true, - configurations: DummyConfigurations - ) + expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, 4, fn -> + [ + enabled: true, + configurations: DummyConfigurations + ] + end) user = build(:user) From ecb38397db0736bfb0a2da785d714c6d0fa32473 Mon Sep 17 00:00:00 2001 From: Nelson Kopliku Date: Wed, 8 Apr 2026 10:11:52 +0200 Subject: [PATCH 8/8] Remove check about feature being enabled in AI entrypoint module --- lib/trento/ai.ex | 18 ++++-------------- lib/trento/ai/user_configuration.ex | 14 +++----------- test/trento/ai_test.exs | 19 +++---------------- 3 files changed, 10 insertions(+), 41 deletions(-) diff --git a/lib/trento/ai.ex b/lib/trento/ai.ex index 4238733bc0..7983be9f4f 100644 --- a/lib/trento/ai.ex +++ b/lib/trento/ai.ex @@ -19,26 +19,16 @@ defmodule Trento.AI do See `Trento.AI.Configurations.create_user_configuration/2` for more details. """ - def create_user_configuration(user, attrs) do - execute_if_enabled(fn -> configurations().create_user_configuration(user, attrs) end) - end + def create_user_configuration(user, attrs), + do: configurations().create_user_configuration(user, attrs) @doc """ Updates a user configuration for AI. See `Trento.AI.Configurations.update_user_configuration/2` for more details. """ - def update_user_configuration(user, attrs) do - execute_if_enabled(fn -> configurations().update_user_configuration(user, attrs) end) - end - - defp execute_if_enabled(function) do - if enabled?() do - function.() - else - {:error, :ai_features_disabled} - end - end + def update_user_configuration(user, attrs), + do: configurations().update_user_configuration(user, attrs) defp configurations, do: Keyword.get(ApplicationConfigLoader.load(), :configurations, Configurations) diff --git a/lib/trento/ai/user_configuration.ex b/lib/trento/ai/user_configuration.ex index 0b62e41b8b..75dc42012b 100644 --- a/lib/trento/ai/user_configuration.ex +++ b/lib/trento/ai/user_configuration.ex @@ -35,18 +35,10 @@ defmodule Trento.AI.UserConfiguration do |> foreign_key_constraint(:user_id, message: "User does not exist") end - defp maybe_update_provider(attrs) do - case Map.has_key?(attrs, :model) do - true -> Map.put(attrs, :provider, get_model_provider(attrs)) - false -> attrs - end - end + defp maybe_update_provider(%{model: model} = attrs), + do: Map.put(attrs, :provider, LLMRegistry.get_model_provider(model)) - defp get_model_provider(attrs) do - attrs - |> Map.get(:model) - |> LLMRegistry.get_model_provider() - end + defp maybe_update_provider(attrs), do: attrs defp validate_model(_model_field_atom, model) do if LLMRegistry.model_supported?(model) do diff --git a/test/trento/ai_test.exs b/test/trento/ai_test.exs index 5ea95e284b..b69cf180fb 100644 --- a/test/trento/ai_test.exs +++ b/test/trento/ai_test.exs @@ -27,22 +27,9 @@ defmodule Trento.AITest do end end - describe "enabling/disabling features" do - test "features are disabled" do - expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, 2, fn -> [enabled: false] end) - user = build(:user) - - attrs = %{ - model: "foo", - api_key: "bar" - } - - assert AI.create_user_configuration(user, attrs) == {:error, :ai_features_disabled} - assert AI.update_user_configuration(user, attrs) == {:error, :ai_features_disabled} - end - - test "features are enabled" do - expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, 4, fn -> + describe "delegating creation and update to configurations module" do + test "should delegate to configurations module" do + expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, 2, fn -> [ enabled: true, configurations: DummyConfigurations