diff --git a/lib/pinchflat/settings/setting.ex b/lib/pinchflat/settings/setting.ex new file mode 100644 index 0000000..117a36f --- /dev/null +++ b/lib/pinchflat/settings/setting.ex @@ -0,0 +1,28 @@ +defmodule Pinchflat.Settings.Setting do + use Ecto.Schema + import Ecto.Changeset + + @allowed_fields [ + :onboarding, + :pro_enabled, + :yt_dlp_version + ] + + @required_fields ~w( + onboarding + pro_enabled + )a + + schema "settings" do + field :onboarding, :boolean, default: false + field :pro_enabled, :boolean, default: false + field :yt_dlp_version, :string + end + + @doc false + def changeset(setting, attrs) do + setting + |> cast(attrs, @allowed_fields) + |> validate_required(@required_fields) + end +end diff --git a/lib/pinchflat/settings/settings.ex b/lib/pinchflat/settings/settings.ex new file mode 100644 index 0000000..4b133c8 --- /dev/null +++ b/lib/pinchflat/settings/settings.ex @@ -0,0 +1,64 @@ +defmodule Pinchflat.Settings do + @moduledoc """ + The Settings context. + """ + import Ecto.Query, warn: false + + alias Pinchflat.Repo + alias Pinchflat.Settings.Setting + + @doc """ + Returns the only setting record. It _should_ be impossible + to create or delete this record, so it's assertive about + assuming it's the only one. + + Returns %Setting{} + """ + def record do + Setting + |> limit(1) + |> Repo.one() + end + + @doc """ + Updates a setting, returning the new value. + Is setup to take a keyword list argument so you + can call it like `Settings.set(onboarding: true)` + + Returns {:ok, value} | {:error, :invalid_key} | {:error, %Ecto.Changeset{}} + """ + def set([{attr, value}]) do + record() + |> Setting.changeset(%{attr => value}) + |> Repo.update() + |> case do + {:ok, %{^attr => _}} -> {:ok, value} + {:ok, _} -> {:error, :invalid_key} + {:error, changeset} -> {:error, changeset} + end + end + + @doc """ + Gets the value of a setting. + + Returns {:ok, value} | {:error, :invalid_key} + """ + def get(name) do + case Map.fetch(record(), name) do + {:ok, value} -> {:ok, value} + :error -> {:error, :invalid_key} + end + end + + @doc """ + Gets the value of a setting, raising if it doesn't exist. + + Returns value + """ + def get!(name) do + case get(name) do + {:ok, value} -> value + {:error, _} -> raise "Setting `#{name}` not found" + end + end +end diff --git a/priv/repo/migrations/20240404174144_create_settings.exs b/priv/repo/migrations/20240404174144_create_settings.exs new file mode 100644 index 0000000..fc906e1 --- /dev/null +++ b/priv/repo/migrations/20240404174144_create_settings.exs @@ -0,0 +1,18 @@ +defmodule Pinchflat.Repo.Migrations.CreateSettings do + use Ecto.Migration + + def up do + create table(:settings) do + add :onboarding, :boolean, default: false, null: false + add :pro_enabled, :boolean, default: false, null: false + add :yt_dlp_version, :string + end + + # Make an initial record because this will be the only one ever inserted + execute "INSERT INTO settings (onboarding, pro_enabled, yt_dlp_version) VALUES (false, false, NULL)" + end + + def down do + drop table(:settings) + end +end diff --git a/test/pinchflat/settings_backup_test.exs b/test/pinchflat/settings_backup_test.exs new file mode 100644 index 0000000..d568e97 --- /dev/null +++ b/test/pinchflat/settings_backup_test.exs @@ -0,0 +1,108 @@ +defmodule Pinchflat.SettingsBackupTest do + use Pinchflat.DataCase + + alias Pinchflat.SettingsBackup + alias Pinchflat.SettingsBackup.SettingBackup + + # NOTE: We're treating some of these tests differently + # than in other modules because certain settings + # are always created on app boot (including in the test env), + # so we can't treat these like a clean slate. + + describe "list_settings/0" do + test "returns all settings" do + SettingsBackup.set!("foo", "bar") + results = SettingsBackup.list_settings() + + assert Enum.all?(results, fn setting -> match?(%SettingBackup{}, setting) end) + end + end + + describe "set/2" do + test "creates a new setting if one does not exist" do + original = Repo.aggregate(SettingBackup, :count, :id) + SettingsBackup.set!("foo", "bar") + assert Repo.aggregate(SettingBackup, :count, :id) == original + 1 + end + + test "updates an existing setting if one exists" do + SettingsBackup.set!("foo", "bar") + original = Repo.aggregate(SettingBackup, :count, :id) + SettingsBackup.set!("foo", "baz") + assert Repo.aggregate(SettingBackup, :count, :id) == original + assert SettingsBackup.get!("foo") == "baz" + end + + test "returns the parsed value" do + assert SettingsBackup.set!("foo", true) == true + assert SettingsBackup.set!("foo", false) == false + assert SettingsBackup.set!("foo", 123) == 123 + assert SettingsBackup.set!("foo", 12.34) == 12.34 + assert SettingsBackup.set!("foo", "bar") == "bar" + end + + test "allows for atom keys" do + assert SettingsBackup.set!(:foo, "bar") == "bar" + end + + test "blows up when an unsupported datatype is used" do + assert_raise FunctionClauseError, fn -> + SettingsBackup.set!("foo", nil) + end + end + end + + describe "set/3" do + test "allows manual specification of datatype" do + assert SettingsBackup.set!("foo", "true", :boolean) == true + assert SettingsBackup.set!("foo", "false", :boolean) == false + assert SettingsBackup.set!("foo", "123", :integer) == 123 + assert SettingsBackup.set!("foo", "12.34", :float) == 12.34 + end + end + + describe "get/1" do + test "returns the value of the setting" do + SettingsBackup.set!("str", "bar") + SettingsBackup.set!("bool", true) + SettingsBackup.set!("int", 123) + SettingsBackup.set!("float", 12.34) + + assert SettingsBackup.get!("str") == "bar" + assert SettingsBackup.get!("bool") == true + assert SettingsBackup.get!("int") == 123 + assert SettingsBackup.get!("float") == 12.34 + end + + test "allows for atom keys" do + SettingsBackup.set!("str", "bar") + assert SettingsBackup.get!(:str) == "bar" + end + + test "blows up when the setting does not exist" do + assert_raise Ecto.NoResultsError, fn -> + SettingsBackup.get!("foo") + end + end + end + + describe "fetch/2" do + test "creates a setting if one doesn't exist" do + original = Repo.aggregate(SettingBackup, :count, :id) + assert SettingsBackup.fetch!("foo", "bar") == "bar" + assert Repo.aggregate(SettingBackup, :count, :id) == original + 1 + end + + test "returns an existing setting if one does exist" do + SettingsBackup.set!("foo", "bar") + + assert SettingsBackup.fetch!("foo", "baz") == "bar" + end + end + + describe "fetch/3" do + test "allows manual specification of datatype" do + assert SettingsBackup.fetch!("foo", "true", :boolean) == true + end + end +end diff --git a/test/pinchflat/settings_test.exs b/test/pinchflat/settings_test.exs index d568e97..bcfb97f 100644 --- a/test/pinchflat/settings_test.exs +++ b/test/pinchflat/settings_test.exs @@ -1,108 +1,63 @@ -defmodule Pinchflat.SettingsBackupTest do +defmodule Pinchflat.SettingsTest do use Pinchflat.DataCase - alias Pinchflat.SettingsBackup - alias Pinchflat.SettingsBackup.SettingBackup + alias Pinchflat.Settings + alias Pinchflat.Settings.Setting # NOTE: We're treating some of these tests differently # than in other modules because certain settings # are always created on app boot (including in the test env), # so we can't treat these like a clean slate. - describe "list_settings/0" do - test "returns all settings" do - SettingsBackup.set!("foo", "bar") - results = SettingsBackup.list_settings() + setup do + # Ensure we have a clean slate + Settings.set(onboarding: false) + Settings.set(pro_enabled: false) + Settings.set(yt_dlp_version: nil) - assert Enum.all?(results, fn setting -> match?(%SettingBackup{}, setting) end) + :ok + end + + describe "record/0" do + test "returns the only setting" do + assert %Setting{} = Settings.record() end end - describe "set/2" do - test "creates a new setting if one does not exist" do - original = Repo.aggregate(SettingBackup, :count, :id) - SettingsBackup.set!("foo", "bar") - assert Repo.aggregate(SettingBackup, :count, :id) == original + 1 + describe "set/1" do + test "updates the setting" do + assert {:ok, true} = Settings.set(onboarding: true) + assert {:ok, true} = Settings.get(:onboarding) end - test "updates an existing setting if one exists" do - SettingsBackup.set!("foo", "bar") - original = Repo.aggregate(SettingBackup, :count, :id) - SettingsBackup.set!("foo", "baz") - assert Repo.aggregate(SettingBackup, :count, :id) == original - assert SettingsBackup.get!("foo") == "baz" + test "returns an error if the setting key doesn't exist" do + assert {:error, :invalid_key} = Settings.set(foo: "bar") end - test "returns the parsed value" do - assert SettingsBackup.set!("foo", true) == true - assert SettingsBackup.set!("foo", false) == false - assert SettingsBackup.set!("foo", 123) == 123 - assert SettingsBackup.set!("foo", 12.34) == 12.34 - assert SettingsBackup.set!("foo", "bar") == "bar" - end - - test "allows for atom keys" do - assert SettingsBackup.set!(:foo, "bar") == "bar" - end - - test "blows up when an unsupported datatype is used" do - assert_raise FunctionClauseError, fn -> - SettingsBackup.set!("foo", nil) - end - end - end - - describe "set/3" do - test "allows manual specification of datatype" do - assert SettingsBackup.set!("foo", "true", :boolean) == true - assert SettingsBackup.set!("foo", "false", :boolean) == false - assert SettingsBackup.set!("foo", "123", :integer) == 123 - assert SettingsBackup.set!("foo", "12.34", :float) == 12.34 + test "returns an error if the setting value is invalid" do + assert {:error, %Ecto.Changeset{}} = Settings.set(onboarding: "bar") end end describe "get/1" do - test "returns the value of the setting" do - SettingsBackup.set!("str", "bar") - SettingsBackup.set!("bool", true) - SettingsBackup.set!("int", 123) - SettingsBackup.set!("float", 12.34) - - assert SettingsBackup.get!("str") == "bar" - assert SettingsBackup.get!("bool") == true - assert SettingsBackup.get!("int") == 123 - assert SettingsBackup.get!("float") == 12.34 + test "returns the setting value" do + assert {:ok, false} = Settings.get(:onboarding) end - test "allows for atom keys" do - SettingsBackup.set!("str", "bar") - assert SettingsBackup.get!(:str) == "bar" + test "returns an error if the setting key doesn't exist" do + assert {:error, :invalid_key} = Settings.get(:foo) + end + end + + describe "get!/1" do + test "returns the setting value" do + assert Settings.get!(:onboarding) == false end - test "blows up when the setting does not exist" do - assert_raise Ecto.NoResultsError, fn -> - SettingsBackup.get!("foo") + test "raises an error if the setting key doesn't exist" do + assert_raise RuntimeError, "Setting `foo` not found", fn -> + Settings.get!(:foo) end end end - - describe "fetch/2" do - test "creates a setting if one doesn't exist" do - original = Repo.aggregate(SettingBackup, :count, :id) - assert SettingsBackup.fetch!("foo", "bar") == "bar" - assert Repo.aggregate(SettingBackup, :count, :id) == original + 1 - end - - test "returns an existing setting if one does exist" do - SettingsBackup.set!("foo", "bar") - - assert SettingsBackup.fetch!("foo", "baz") == "bar" - end - end - - describe "fetch/3" do - test "allows manual specification of datatype" do - assert SettingsBackup.fetch!("foo", "true", :boolean) == true - end - end end