From e388cf5af59b6e332187d903b191be7d5921f117 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Sat, 18 Jan 2025 23:06:55 +0100 Subject: [PATCH] Add persistence of reminders and some rudimentary tests --- config/test.exs | 6 +- lib/jenot/note.ex | 1 + lib/jenot/notes.ex | 151 +++++++++++++++--- lib/jenot/reminder.ex | 25 ++- mix.exs | 15 ++ ...20241215203400_update_reminders_schema.exs | 14 +- priv/static/js/synced-store.js | 2 +- test/jenot/notes_test.exs | 124 ++++++++++++++ test/jenot_test.exs | 8 - test/support/data_case.ex | 23 +++ test/test_helper.exs | 2 + 11 files changed, 327 insertions(+), 44 deletions(-) create mode 100644 test/jenot/notes_test.exs delete mode 100644 test/jenot_test.exs create mode 100644 test/support/data_case.ex diff --git a/config/test.exs b/config/test.exs index 26e2ac5..db3eb6b 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,9 @@ import Config -config :jenot, Jenot.Repo, database: :memory +config :jenot, Jenot.Repo, + database: "priv/db_test.sqlite", + pool: Ecto.Adapters.SQL.Sandbox + +config :logger, :default_handler, level: :warning config :bcrypt_elixir, :log_rounds, 4 diff --git a/lib/jenot/note.ex b/lib/jenot/note.ex index 8b77831..78cadde 100644 --- a/lib/jenot/note.ex +++ b/lib/jenot/note.ex @@ -12,6 +12,7 @@ defmodule Jenot.Note do field(:deleted_at, :utc_datetime_usec) belongs_to(:account, Jenot.Account, type: :binary_id) + has_one(:reminder, Jenot.Reminder) field(:server_updated_at, :utc_datetime_usec) timestamps(type: :utc_datetime_usec) diff --git a/lib/jenot/notes.ex b/lib/jenot/notes.ex index 15b7327..7611db1 100644 --- a/lib/jenot/notes.ex +++ b/lib/jenot/notes.ex @@ -2,6 +2,7 @@ defmodule Jenot.Notes do import Ecto.Query alias Jenot.Note + alias Jenot.Reminder alias Jenot.Repo def serialize(note) do @@ -13,6 +14,7 @@ defmodule Jenot.Notes do |> where(account_id: ^account.id) |> select([n], max(n.server_updated_at)) |> Repo.one() + |> Repo.preload(:reminder) end def all(account, since \\ nil, include_deleted? \\ false) do @@ -21,15 +23,20 @@ defmodule Jenot.Notes do |> maybe_include_deleted(include_deleted?) |> maybe_filter_since(since) |> Repo.all() + |> Repo.preload(:reminder) end def add(account, params) do params = deserialize_params(params) - changeset = Note.new(account, params) + note_changeset = Note.new(account, params) - case upsert(changeset, target: [:account_id, :internal_id]) do - {:ok, note} -> {:ok, note} - {:error, _changeset} -> {:error, :invalid_data} + with {:ok, note} <- upsert_note(note_changeset, target: [:account_id, :internal_id]), + reminder_changeset = Reminder.update(note, params["reminder"]), + {:ok, reminder} <- upsert_reminder(reminder_changeset) do + {:ok, %{note | reminder: reminder}} + else + {:error, _} -> + {:error, :invalid_data} end end @@ -39,6 +46,7 @@ defmodule Jenot.Notes do |> where(account_id: ^account.id) |> where(internal_id: ^internal_id) |> Repo.one() + |> Repo.preload(:reminder) if note do {:ok, note} @@ -51,11 +59,14 @@ defmodule Jenot.Notes do params = deserialize_params(params) with {:ok, note} <- note_by_internal_id(account, internal_id) do - changeset = Note.update(note, params) + note_changeset = Note.update(note, params) - case upsert(changeset, target: [:id]) do - {:ok, note} -> {:ok, note} - {:error, _changeset} -> {:error, :invalid_data} + with {:ok, note} <- upsert_note(note_changeset, target: [:id]), + reminder_changeset = Reminder.update(note, params["reminder"]), + {:ok, reminder} <- upsert_reminder(reminder_changeset) do + {:ok, %{note | reminder: reminder}} + else + {:error, _} -> {:error, :invalid_data} end end end @@ -70,55 +81,83 @@ defmodule Jenot.Notes do defp deserialize_params(params) do params = - case Map.get(params, "content", Map.get(params, :content)) do + case Map.get(params, "content") do data when is_list(data) -> Map.put(params, "content", Jason.encode!(data)) _ -> params end - case Map.get(params, "id", Map.get(params, :id)) do + case Map.get(params, "id") do nil -> params internal_id -> - params - |> Map.delete("id") - |> Map.delete(:id) - |> Map.put("internal_id", internal_id) - |> deserialize_timestamp(:deleted, :deleted_at) - |> deserialize_timestamp(:created, :inserted_at) - |> deserialize_timestamp(:updated, :updated_at) + params = + params + |> Map.delete("id") + |> Map.put("internal_id", internal_id) + |> deserialize_timestamp("deleted", "deleted_at") + |> deserialize_timestamp("created", "inserted_at") + |> deserialize_timestamp("updated", "updated_at") + + if reminder = params["reminder"] do + reminder = + reminder + |> Map.put("inserted_at", params["inserted_at"]) + |> Map.put("updated_at", params["updated_at"]) + + Map.put(params, "reminder", reminder) + else + params + end end end defp serialize_note(note) do note = note - |> Map.take([:title, :type, :content, :inserted_at, :updated_at, :deleted_at]) + |> Map.take([:title, :type, :content, :inserted_at, :updated_at, :deleted_at, :reminder]) |> Map.put(:id, note.internal_id) |> serialize_timestamp(:deleted_at, :deleted) |> serialize_timestamp(:inserted_at, :created) |> serialize_timestamp(:updated_at, :updated) - if note.type == :tasklist and not is_nil(note.content) do - %{note | content: Jason.decode!(note.content)} + note = + if note.type == :tasklist and not is_nil(note.content) do + %{note | content: Jason.decode!(note.content)} + else + note + end + + if reminder = note.reminder do + time = + reminder.time + |> Time.to_string() + |> String.split(":") + |> Enum.take(2) + |> Enum.join(":") + + reminder = + reminder + |> Map.take([:date, :time, :repeat, :unit, :enabled]) + |> Map.put(:date, Date.to_iso8601(reminder.date)) + |> Map.put(:time, time) + + %{note | reminder: reminder} else note end end defp deserialize_timestamp(params, src_key, dst_key) do - str_src_key = to_string(src_key) - value = - case Map.get(params, str_src_key, Map.get(params, src_key)) do + case Map.get(params, src_key) do nil -> nil unix_time -> DateTime.from_unix!(unix_time, :millisecond) end params |> Map.delete(src_key) - |> Map.delete(str_src_key) - |> Map.put(to_string(dst_key), value) + |> Map.put(dst_key, value) end defp serialize_timestamp(data, src_key, dst_key) do @@ -150,7 +189,67 @@ defmodule Jenot.Notes do where(query, [n], n.server_updated_at > ^datetime) end - defp upsert(changeset, opts) do + defp upsert_reminder(nil), do: {:ok, nil} + + defp upsert_reminder(changeset) do + reminder = Ecto.Changeset.apply_changes(changeset) + + conflict_query = + from(r in Reminder, + update: [ + set: [ + date: + fragment( + "CASE WHEN ? > ? THEN ? ELSE ? END", + ^reminder.updated_at, + r.updated_at, + ^reminder.date, + r.date + ), + time: + fragment( + "CASE WHEN ? > ? THEN ? ELSE ? END", + ^reminder.updated_at, + r.updated_at, + ^reminder.time, + r.time + ), + repeat: + fragment( + "CASE WHEN ? > ? THEN ? ELSE ? END", + ^reminder.updated_at, + r.updated_at, + ^reminder.repeat, + r.repeat + ), + unit: + fragment( + "CASE WHEN ? > ? THEN ? ELSE ? END", + ^reminder.updated_at, + r.updated_at, + ^reminder.unit, + r.unit + ), + enabled: + fragment( + "CASE WHEN ? > ? THEN ? ELSE ? END", + ^reminder.updated_at, + r.updated_at, + type(^reminder.enabled, :boolean), + type(r.enabled, :boolean) + ) + ] + ] + ) + + Repo.insert(changeset, + on_conflict: conflict_query, + conflict_target: [:note_id], + returning: true + ) + end + + defp upsert_note(changeset, opts) do conflict_target = Keyword.fetch!(opts, :target) type = Ecto.Changeset.get_field(changeset, :type) || :note title = Ecto.Changeset.get_field(changeset, :title) || "" diff --git a/lib/jenot/reminder.ex b/lib/jenot/reminder.ex index 6cb0696..ed172ba 100644 --- a/lib/jenot/reminder.ex +++ b/lib/jenot/reminder.ex @@ -1,16 +1,35 @@ defmodule Jenot.Reminder do use Ecto.Schema + import Ecto.Changeset + @primary_key false schema "reminders" do field(:date, :date) field(:time, :time) - field(:repeat, Ecto.Enum, values: [:day, :week, :month, :year]) - field(:unit, :integer) + field(:repeat, :integer) + field(:unit, Ecto.Enum, values: [:day, :week, :month, :year]) field(:enabled, :boolean) - belongs_to(:note, Jenot.Note) + belongs_to(:note, Jenot.Note, type: :binary_id) timestamps(type: :utc_datetime_usec) end + + def update(_note, nil), do: nil + + def update(note, params) do + %__MODULE__{} + |> cast(params, [ + :date, + :time, + :repeat, + :unit, + :enabled, + :inserted_at, + :updated_at + ]) + |> validate_required([:date, :time, :enabled, :inserted_at, :updated_at]) + |> put_assoc(:note, note) + end end diff --git a/mix.exs b/mix.exs index a47d451..67a6146 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,9 @@ defmodule Jenot.MixProject do app: :jenot, version: "0.1.0", elixir: "~> 1.17", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, + aliases: aliases(), deps: deps(), releases: releases() ] @@ -19,6 +21,11 @@ defmodule Jenot.MixProject do ] end + defp elixirc_paths(env) when env in [:test, :dev], + do: ["lib", "test/support"] + + defp elixirc_paths(_), do: ["lib"] + def releases do [ jenot: [ @@ -44,4 +51,12 @@ defmodule Jenot.MixProject do {:plug_live_reload, "~> 0.1.0", only: :dev} ] end + + defp aliases do + [ + setup: ["deps.get", "ecto.create", "ecto.migrate"], + "ecto.reset": ["ecto.drop", "ecto.create", "ecto.migrate"], + test: ["ecto.create --quiet", "ecto.migrate", "test"] + ] + end end diff --git a/priv/repo/migrations/20241215203400_update_reminders_schema.exs b/priv/repo/migrations/20241215203400_update_reminders_schema.exs index 44077b3..85aeba9 100644 --- a/priv/repo/migrations/20241215203400_update_reminders_schema.exs +++ b/priv/repo/migrations/20241215203400_update_reminders_schema.exs @@ -1,19 +1,23 @@ defmodule Jenot.Repo.Migrations.UpdateRemindersSchema do use Ecto.Migration - def change do - drop table(:reminders) + def up do + drop_if_exists table(:reminders) create table(:reminders, primary_key: false) do add :date, :date, null: false add :time, :time, null: false - add :repeat_period, :text, null: false - add :repeat_count, :integer, null: false + add :repeat, :integer, null: true + add :unit, :text, null: true add :enabled, :boolean, null: false - add :note_id, references(:note, on_delete: :delete_all), null: false, primary_key: true + add :note_id, references(:notes, on_delete: :delete_all, type: :binary_id), primary_key: true timestamps(type: :datetime_usec) end end + + def down do + drop_if_exists table(:reminders) + end end diff --git a/priv/static/js/synced-store.js b/priv/static/js/synced-store.js index 5257a76..103f59d 100644 --- a/priv/static/js/synced-store.js +++ b/priv/static/js/synced-store.js @@ -172,7 +172,7 @@ export class SyncedNoteStore extends EventTarget { date: note.reminder.date, time: note.reminder.time, repeat: note.reminder.count, - unit: note.reminder.unit, + unit: note.reminder.unit != "" ? note.reminder.unit : null, } : null, created: now, diff --git a/test/jenot/notes_test.exs b/test/jenot/notes_test.exs new file mode 100644 index 0000000..eba0167 --- /dev/null +++ b/test/jenot/notes_test.exs @@ -0,0 +1,124 @@ +defmodule Jenot.NotesTest do + use Jenot.DataCase + + alias Jenot.Notes + + setup do + Jenot.Accounts.new() + end + + describe "add/2" do + test "adds a new note", %{account: account} do + now = DateTime.utc_now(:millisecond) + timestamp = DateTime.to_unix(now, :millisecond) + + assert {:ok, note} = + Notes.add(account, %{ + "id" => "id_#{timestamp}", + "type" => "note", + "content" => "Example note body", + "reminder" => nil, + "created" => timestamp, + "updated" => timestamp, + "deleted" => nil + }) + + assert is_binary(note.id) + assert note.internal_id == "id_#{timestamp}" + assert DateTime.compare(now, note.updated_at) == :eq + end + + test "adds a new tasklist note", %{account: account} do + now = DateTime.utc_now(:millisecond) + timestamp = DateTime.to_unix(now, :millisecond) + + assert {:ok, note} = + Notes.add(account, %{ + "id" => "id_#{timestamp}", + "type" => "tasklist", + "content" => [ + %{checked: true, content: "First task"}, + %{checked: false, content: "Second task"} + ], + "reminder" => nil, + "created" => timestamp, + "updated" => timestamp, + "deleted" => nil + }) + + assert is_binary(note.id) + assert note.internal_id == "id_#{timestamp}" + + assert note.content == + Jason.encode!([ + %{checked: true, content: "First task"}, + %{checked: false, content: "Second task"} + ]) + + assert DateTime.compare(now, note.updated_at) == :eq + end + + test "adds a new note with a reminder", %{account: account} do + now = DateTime.utc_now(:millisecond) + timestamp = DateTime.to_unix(now, :millisecond) + + assert {:ok, note} = + Notes.add(account, %{ + "id" => "id_#{timestamp}", + "type" => "note", + "content" => "Example note body", + "reminder" => %{ + "enabled" => true, + "date" => "2025-04-01", + "time" => "13:00", + "repeat" => 1, + "unit" => nil + }, + "created" => timestamp, + "updated" => timestamp, + "deleted" => nil + }) + + assert note.internal_id == "id_#{timestamp}" + assert note.reminder.note_id == note.id + assert note.reminder.date == ~D[2025-04-01] + assert note.reminder.time == ~T[13:00:00] + end + end + + describe "serialize/1" do + test "serializes note with a reminder", %{account: account} do + now = DateTime.utc_now(:millisecond) + timestamp = DateTime.to_unix(now, :millisecond) + + assert {:ok, note} = + Notes.add(account, %{ + "id" => "id_#{timestamp}", + "type" => "note", + "content" => "Example note body", + "reminder" => %{ + "enabled" => true, + "date" => "2025-04-01", + "time" => "13:00", + "repeat" => 1, + "unit" => nil + }, + "created" => timestamp, + "updated" => timestamp, + "deleted" => nil + }) + + serialized = Notes.serialize(note) + + assert serialized.id == note.internal_id + assert serialized.reminder.date == "2025-04-01" + assert serialized.reminder.time == "13:00" + end + end + + describe "all/0,1,2" do + test "returns no notes for new account", %{account: account} do + assert [] = Notes.all(account) + end + end +end diff --git a/test/jenot_test.exs b/test/jenot_test.exs deleted file mode 100644 index af3a290..0000000 --- a/test/jenot_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule JenotTest do - use ExUnit.Case - doctest Jenot - - test "greets the world" do - assert Jenot.hello() == :world - end -end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..c01f764 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,23 @@ +defmodule Jenot.DataCase do + use ExUnit.CaseTemplate + + using do + quote do + alias Jenot.Repo + + import Ecto + import Ecto.Query + import Jenot.DataCase + end + end + + setup tags do + if tags[:async] do + raise "SQLite3 does not support async testing in sandbox mode" + end + + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Jenot.Repo, shared: true) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..5cb5c76 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,3 @@ ExUnit.start() + +Ecto.Adapters.SQL.Sandbox.mode(Jenot.Repo, :manual)