mirror of
https://github.com/zoldar/jenot.git
synced 2026-01-03 14:32:54 +00:00
Add persistence of reminders and some rudimentary tests
This commit is contained in:
parent
e586e10345
commit
e388cf5af5
11 changed files with 327 additions and 44 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) || ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
mix.exs
15
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
124
test/jenot/notes_test.exs
Normal file
124
test/jenot/notes_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
defmodule JenotTest do
|
||||
use ExUnit.Case
|
||||
doctest Jenot
|
||||
|
||||
test "greets the world" do
|
||||
assert Jenot.hello() == :world
|
||||
end
|
||||
end
|
||||
23
test/support/data_case.ex
Normal file
23
test/support/data_case.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -1 +1,3 @@
|
|||
ExUnit.start()
|
||||
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Jenot.Repo, :manual)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue