diff --git a/lib/trento/ai.ex b/lib/trento/ai.ex new file mode 100644 index 0000000000..7983be9f4f --- /dev/null +++ b/lib/trento/ai.ex @@ -0,0 +1,35 @@ +defmodule Trento.AI do + @moduledoc """ + 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 """ + Checks if the AI features are enabled. + """ + @spec enabled?() :: boolean() + def enabled?, + do: Keyword.get(ApplicationConfigLoader.load(), :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: 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: configurations().update_user_configuration(user, attrs) + + 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/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/lib/trento/ai/llm_registry.ex b/lib/trento/ai/llm_registry.ex new file mode 100644 index 0000000000..ff18510f39 --- /dev/null +++ b/lib/trento/ai/llm_registry.ex @@ -0,0 +1,53 @@ +defmodule Trento.AI.LLMRegistry do + @moduledoc """ + 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. + """ + @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: Keyword.get(ApplicationConfigLoader.load(), :providers, []) +end diff --git a/lib/trento/ai/user_configuration.ex b/lib/trento/ai/user_configuration.ex new file mode 100644 index 0000000000..75dc42012b --- /dev/null +++ b/lib/trento/ai/user_configuration.ex @@ -0,0 +1,50 @@ +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(%{model: model} = attrs), + do: Map.put(attrs, :provider, LLMRegistry.get_model_provider(model)) + + defp maybe_update_provider(attrs), do: attrs + + 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 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 c0adbe6a5c..8c059a5d7f 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,25 @@ defmodule Trento.Factory do target_type: Enum.random(["host", "cluster"]) } end + + def random_ai_model do + Trento.AI.AICase.stub_config_loader() + + :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/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 new file mode 100644 index 0000000000..12c7ed3117 --- /dev/null +++ b/test/trento/ai/configurations_test.exs @@ -0,0 +1,402 @@ +defmodule Trento.Ai.ConfigurationsTest do + use Trento.DataCase, async: true + use Trento.AI.AICase + + 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 diff --git a/test/trento/ai/llm_registry_test.exs b/test/trento/ai/llm_registry_test.exs new file mode 100644 index 0000000000..555096bd5b --- /dev/null +++ b/test/trento/ai/llm_registry_test.exs @@ -0,0 +1,116 @@ +defmodule Trento.AI.LLMRegistryTest do + use ExUnit.Case, async: true + + alias Trento.AI.LLMRegistry + + 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" + ] + ] + ] + ] + 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" + ] + + 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 + 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", + "model3", + "model4", + "model5", + "model6" + ] + end + end + + 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 +end diff --git a/test/trento/ai_test.exs b/test/trento/ai_test.exs new file mode 100644 index 0000000000..b69cf180fb --- /dev/null +++ b/test/trento/ai_test.exs @@ -0,0 +1,60 @@ +defmodule Trento.AITest do + use ExUnit.Case + + alias Trento.AI + + import Trento.Factory + + import Mox + + setup :verify_on_exit! + + describe "enabled?/0" do + test "returns true when AI features are enabled" do + 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 + 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 + expect(Trento.AI.ApplicationConfigLoader.Mock, :load_config, fn -> [] end) + assert AI.enabled?() == false + end + end + + 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 + ] + end) + + 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 + +defmodule DummyConfigurations do + def create_user_configuration(_user, _attrs) do + {:ok, :created} + end + + def update_user_configuration(_user, _attrs) do + {:ok, :updated} + end +end