From f61fc2552ca014abae8990297f379bed7cd92904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20W=C3=B6ginger?= Date: Thu, 25 Apr 2024 21:15:01 +0200 Subject: [PATCH] Feature/add more commands events (#523) --- assets/js/hooks/events/handler.ts | 35 ++++++++--- assets/js/hooks/events/listener.ts | 9 ++- assets/js/hooks/item.ts | 26 +++----- assets/js/hooks/types.ts | 4 +- lib/radiator/application.ex | 2 +- lib/radiator/event_store.ex | 2 +- lib/radiator/outline.ex | 20 +++--- lib/radiator/outline/command.ex | 12 +++- .../command/change_node_content_command.ex | 13 ++++ .../outline/command/delete_node_command.ex | 23 +++++++ .../outline/command/move_node_command.ex | 14 +++++ lib/radiator/outline/dispatch.ex | 24 ++++++-- .../event/node_content_changed_event.ex | 5 ++ .../outline/event/node_deleted_event.ex | 3 + .../outline/event/node_moved_event.ex | 3 + lib/radiator/outline/event_consumer.ex | 43 ++++++++++--- lib/radiator/outline/event_producer.ex | 27 +++----- lib/radiator/outline/node.ex | 17 +++++- lib/radiator_web/live/episode_live/index.ex | 41 +++++++------ priv/repo/seeds.exs | 54 ++++++++++------ test/radiator/outline/dispatch_test.exs | 43 +++++++++---- test/radiator/outline/event_consumer_test.exs | 56 +++++++++++++++++ test/radiator/outline/node_test.exs | 50 +++++++++++++++ test/radiator/outline_test.exs | 11 ++-- test/radiator_web/live/episode_live_test.exs | 61 +++++++++++++------ 25 files changed, 450 insertions(+), 148 deletions(-) create mode 100644 lib/radiator/outline/command/change_node_content_command.ex create mode 100644 lib/radiator/outline/command/delete_node_command.ex create mode 100644 lib/radiator/outline/command/move_node_command.ex create mode 100644 lib/radiator/outline/event/node_content_changed_event.ex create mode 100644 lib/radiator/outline/event/node_deleted_event.ex create mode 100644 lib/radiator/outline/event/node_moved_event.ex create mode 100644 test/radiator/outline/node_test.exs diff --git a/assets/js/hooks/events/handler.ts b/assets/js/hooks/events/handler.ts index aacec84a..ebcc6666 100644 --- a/assets/js/hooks/events/handler.ts +++ b/assets/js/hooks/events/handler.ts @@ -1,13 +1,20 @@ -import { Node } from "../types"; -import { createItem, updateItem, deleteItem, focusItem } from "../item"; +import { Node, UUID } from "../types"; +import { + getItemByNode, + createItem, + updateItem, + deleteItem, + focusItem, +} from "../item"; export function handleList({ nodes }: { nodes: Node[] }) { const container: HTMLOListElement = this.el; if (nodes.length == 0) { const node: Node = { - temp_id: self.crypto.randomUUID(), + uuid: self.crypto.randomUUID(), content: "", + event_id: self.crypto.randomUUID(), dirty: true, }; nodes = [node]; @@ -29,19 +36,31 @@ export function handleList({ nodes }: { nodes: Node[] }) { focusItem(lastItem); } -export function handleInsert(node: Node) { +interface NodeEvent { + node: Node; + event_id: UUID; +} + +export function handleInsert({ node, event_id }: NodeEvent) { const container: HTMLOListElement = this.el; - const item = createItem(node); - container.append(item); + const item = getItemByNode(node); + if (item) { + node.dirty = false; + updateItem(node, container); + } else { + const newItem = createItem(node); + container.append(newItem); + } } -export function handleUpdate(node: Node) { +export function handleUpdate({ node, event_id }: NodeEvent) { const container: HTMLOListElement = this.el; + node.dirty = false; updateItem(node, container); } -export function handleDelete(node: Node) { +export function handleDelete({ node, event_id }: NodeEvent) { deleteItem(node); } diff --git a/assets/js/hooks/events/listener.ts b/assets/js/hooks/events/listener.ts index a8277f18..d4f629ac 100644 --- a/assets/js/hooks/events/listener.ts +++ b/assets/js/hooks/events/listener.ts @@ -22,6 +22,7 @@ export function focusout(event: FocusEvent) { export function input(event: Event) { const node = getNodeByEvent(event); + node.event_id = self.crypto.randomUUID(); node.dirty = true; this.pushEvent("update_node", node); } @@ -71,16 +72,18 @@ export function keydown(event: KeyboardEvent) { const content = node.content; node.content = content?.substring(0, splitPos); + node.event_id = self.crypto.randomUUID(); node.dirty = true; updateItem(node, container); this.pushEvent("update_node", node); const newNode: Node = { - temp_id: self.crypto.randomUUID(), + uuid: self.crypto.randomUUID(), content: content?.substring(splitPos), parent_id: node.parent_id, prev_id: node.uuid, + event_id: self.crypto.randomUUID(), dirty: true, }; @@ -101,10 +104,12 @@ export function keydown(event: KeyboardEvent) { prevNode.content += node.content; updateItem(prevNode, container); focusItem(prevItem); + prevNode.event_id = self.crypto.randomUUID(); prevNode.dirty = true; this.pushEvent("update_node", prevNode); deleteItem(node); + node.event_id = self.crypto.randomUUID(); node.dirty = true; this.pushEvent("delete_node", node); break; @@ -118,10 +123,12 @@ export function keydown(event: KeyboardEvent) { node.content += nextNode.content; updateItem(node, container); focusItem(item); + node.event_id = self.crypto.randomUUID(); node.dirty = true; this.pushEvent("update_node", node); deleteItem(nextNode); + nextNode.event_id = self.crypto.randomUUID(); nextNode.dirty = true; this.pushEvent("delete_node", nextNode); break; diff --git a/assets/js/hooks/item.ts b/assets/js/hooks/item.ts index f004475e..1b4d3b71 100644 --- a/assets/js/hooks/item.ts +++ b/assets/js/hooks/item.ts @@ -1,13 +1,6 @@ import { Node } from "./types"; -export function createItem({ - uuid, - temp_id, - content, - parent_id, - prev_id, - dirty, -}: Node) { +export function createItem({ uuid, content, parent_id, prev_id, dirty }: Node) { const input = document.createElement("div"); input.textContent = content; input.contentEditable = "true"; // firefox does not support "plaintext-only" @@ -16,9 +9,7 @@ export function createItem({ ol.className = "list-disc"; const item = document.createElement("li"); - temp_id && (item.id = "outline-node-" + temp_id); - uuid && (item.id = "outline-node-" + uuid); - + item.id = "outline-node-" + uuid; item.className = "my-1 ml-4"; item.setAttribute("data-parent", parent_id || ""); @@ -32,19 +23,18 @@ export function createItem({ } export function updateItem( - { uuid, temp_id, content, parent_id, prev_id }: Node, - container: HTMLOListElement, + { uuid, content, parent_id, prev_id, dirty }: Node, + container: HTMLOListElement ) { - const item = getItemById(temp_id || uuid); + const item = getItemById(uuid); if (!item) return; - temp_id && uuid && (item.id = "outline-node-" + uuid); - const input = item.firstChild!; input.textContent = content; item.setAttribute("data-parent", parent_id || ""); item.setAttribute("data-prev", prev_id || ""); + item.setAttribute("data-dirty", dirty ? "true" : "false"); const prevItem = getItemById(prev_id); const parentItem = getItemById(parent_id); @@ -65,8 +55,8 @@ export function deleteItem({ uuid }: Node) { item.parentNode!.removeChild(item); } -export function getItemByNode({ uuid, temp_id }: Node) { - return getItemById(temp_id || uuid); +export function getItemByNode({ uuid }: Node) { + return getItemById(uuid); } function getItemById(uuid: string | undefined) { diff --git a/assets/js/hooks/types.ts b/assets/js/hooks/types.ts index 85eb46da..33a60952 100644 --- a/assets/js/hooks/types.ts +++ b/assets/js/hooks/types.ts @@ -1,11 +1,11 @@ export type UUID = `${string}-${string}-${string}-${string}-${string}`; export interface Node { - uuid?: UUID; - temp_id?: UUID; + uuid: UUID; content: string; creator_id?: number; parent_id?: UUID; prev_id?: UUID; + event_id?: UUID; dirty?: boolean; } diff --git a/lib/radiator/application.ex b/lib/radiator/application.ex index 7920efbb..a85ed9c2 100644 --- a/lib/radiator/application.ex +++ b/lib/radiator/application.ex @@ -22,7 +22,7 @@ defmodule Radiator.Application do # Start to serve requests, typically the last entry RadiatorWeb.Endpoint, {EventProducer, name: EventProducer}, - {EventConsumer, name: EventConsumer} + {EventConsumer, name: EventConsumer, subscribe_to: [{EventProducer, max_demand: 1}]} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/radiator/event_store.ex b/lib/radiator/event_store.ex index 9939702c..6db8d09d 100644 --- a/lib/radiator/event_store.ex +++ b/lib/radiator/event_store.ex @@ -5,6 +5,6 @@ defmodule Radiator.EventStore do def persist_event(event) do # persist event - {:ok, event} + event end end diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index e8e665bd..e765565e 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -27,7 +27,6 @@ defmodule Radiator.Outline do # if no parent is given, the new node will be inserted as a root node # if no previous node is given, the new node will be inserted as the first child of the parent node def insert_node(attrs) do - # FIXME: missing test required episode_id . .it crashes without given Repo.transaction(fn -> prev_node_id = attrs["prev_node"] parent_node_id = attrs["parent_node"] @@ -62,17 +61,22 @@ defmodule Radiator.Outline do ## Examples - iex> update_node_content(node, %{content: new_value}) + iex> update_node_content(node_id, %{content: new_value}) {:ok, %Node{}} - iex> update_node_content(node, %{content: nil}) + iex> update_node_content(node_id, %{content: nil}) {:error, %Ecto.Changeset{}} - """ - def update_node_content(%Node{} = node, attrs, _socket_id \\ nil) do - node - |> Node.update_content_changeset(attrs) - |> Repo.update() + def update_node_content(node_id, content) do + case NodeRepository.get_node(node_id) do + nil -> + {:error, :not_found} + + node -> + node + |> Node.update_content_changeset(%{content: content}) + |> Repo.update() + end end @doc """ diff --git a/lib/radiator/outline/command.ex b/lib/radiator/outline/command.ex index ef5c6134..89d8d474 100644 --- a/lib/radiator/outline/command.ex +++ b/lib/radiator/outline/command.ex @@ -1,8 +1,7 @@ defmodule Radiator.Outline.Command do @moduledoc false - alias Radiator.Outline.Command.InsertNodeCommand - defstruct [:event_type, :event_id, :user_id, :payload] + alias Radiator.Outline.Command.{ChangeNodeContentCommand, InsertNodeCommand} def build("insert_node", payload, user_id, event_id) do %InsertNodeCommand{ @@ -11,4 +10,13 @@ defmodule Radiator.Outline.Command do payload: payload } end + + def build("change_node_content", node_id, content, user_id, event_id) do + %ChangeNodeContentCommand{ + event_id: event_id, + user_id: user_id, + node_id: node_id, + content: content + } + end end diff --git a/lib/radiator/outline/command/change_node_content_command.ex b/lib/radiator/outline/command/change_node_content_command.ex new file mode 100644 index 00000000..c3252389 --- /dev/null +++ b/lib/radiator/outline/command/change_node_content_command.ex @@ -0,0 +1,13 @@ +defmodule Radiator.Outline.Command.ChangeNodeContentCommand do + @moduledoc """ + Command to move a nodeinside the outline to another place. + """ + @type t() :: %__MODULE__{ + event_id: binary(), + user_id: binary(), + node_id: binary(), + content: String.t() | nil + } + + defstruct [:event_id, :user_id, :node_id, :content] +end diff --git a/lib/radiator/outline/command/delete_node_command.ex b/lib/radiator/outline/command/delete_node_command.ex new file mode 100644 index 00000000..39062422 --- /dev/null +++ b/lib/radiator/outline/command/delete_node_command.ex @@ -0,0 +1,23 @@ +defmodule Radiator.Outline.Command.DeleteNodeCommand do + @moduledoc """ + Command to remove a node from the outline and delete it permantly. + """ + @type t() :: %__MODULE__{ + event_id: binary(), + user_id: binary(), + node_id: binary() + } + + defstruct [:event_id, :user_id, :node_id] + + # def execute(%{id: id}) do + # case Radiator.Outline.Node.get(id) do + # nil -> + # {:error, "Node not found"} + + # node -> + # Radiator.Outline.Node.delete(node) + # {:ok, %{}} + # end + # end +end diff --git a/lib/radiator/outline/command/move_node_command.ex b/lib/radiator/outline/command/move_node_command.ex new file mode 100644 index 00000000..9da5da8d --- /dev/null +++ b/lib/radiator/outline/command/move_node_command.ex @@ -0,0 +1,14 @@ +defmodule Radiator.Outline.Command.MoveNodeCommand do + @moduledoc """ + Command to move a nodeinside the outline to another place. + """ + @type t() :: %__MODULE__{ + event_id: binary(), + user_id: binary(), + node_id: binary(), + parent_node_id: binary() | nil, + prev_node_id: binary() | nil + } + + defstruct [:event_id, :user_id, :node_id, :parent_node_id, :prev_node_id] +end diff --git a/lib/radiator/outline/dispatch.ex b/lib/radiator/outline/dispatch.ex index 4dab56c9..97e78219 100644 --- a/lib/radiator/outline/dispatch.ex +++ b/lib/radiator/outline/dispatch.ex @@ -10,6 +10,25 @@ defmodule Radiator.Outline.Dispatch do |> EventProducer.enqueue() end + def change_node_content(node_id, content, user_id, event_id) do + # IO.inspect(node_id, label: "Dispatcher change_node_content") + "change_node_content" + |> Command.build(node_id, content, user_id, event_id) + |> EventProducer.enqueue() + end + + # def move_node(attributes, user_id, event_id) do + # "move_node" + # |> Command.build(attributes, user_id, event_id) + # |> EventProducer.enqueue() + # end + + # def delete_node(node_id, user_id, event_id) do + # "delete_node" + # |> Command.build(node_id, user_id, event_id) + # |> EventProducer.enqueue() + # end + def subscribe(_episode_id) do Phoenix.PubSub.subscribe(Radiator.PubSub, "events") end @@ -18,10 +37,5 @@ defmodule Radiator.Outline.Dispatch do Phoenix.PubSub.broadcast(Radiator.PubSub, "events", event) end - # TODO - # update_node - # delete_node - # move_node - # list_node different case, sync call end diff --git a/lib/radiator/outline/event/node_content_changed_event.ex b/lib/radiator/outline/event/node_content_changed_event.ex new file mode 100644 index 00000000..7059eb71 --- /dev/null +++ b/lib/radiator/outline/event/node_content_changed_event.ex @@ -0,0 +1,5 @@ +defmodule Radiator.Outline.Event.NodeContentChangedEvent do + @moduledoc false + + defstruct [:event_id, :node] +end diff --git a/lib/radiator/outline/event/node_deleted_event.ex b/lib/radiator/outline/event/node_deleted_event.ex new file mode 100644 index 00000000..9eaa521a --- /dev/null +++ b/lib/radiator/outline/event/node_deleted_event.ex @@ -0,0 +1,3 @@ +defmodule Radiator.Outline.Event.NodeDeletedEvent do + @moduledoc false +end diff --git a/lib/radiator/outline/event/node_moved_event.ex b/lib/radiator/outline/event/node_moved_event.ex new file mode 100644 index 00000000..797a0c2c --- /dev/null +++ b/lib/radiator/outline/event/node_moved_event.ex @@ -0,0 +1,3 @@ +defmodule Radiator.Outline.Event.NodeMovedEvent do + @moduledoc false +end diff --git a/lib/radiator/outline/event_consumer.ex b/lib/radiator/outline/event_consumer.ex index aab5e36d..ddfcb710 100644 --- a/lib/radiator/outline/event_consumer.ex +++ b/lib/radiator/outline/event_consumer.ex @@ -3,19 +3,19 @@ defmodule Radiator.Outline.EventConsumer do use GenStage + alias Radiator.EventStore alias Radiator.Outline - alias Radiator.Outline.Command.InsertNodeCommand - alias Radiator.Outline.Event.NodeInsertedEvent + alias Radiator.Outline.Command.{ChangeNodeContentCommand, InsertNodeCommand} alias Radiator.Outline.Dispatch - alias Radiator.Outline.EventProducer - alias Radiator.EventStore + alias Radiator.Outline.Event.{NodeContentChangedEvent, NodeInsertedEvent} def start_link(opts \\ []) do - GenStage.start_link(__MODULE__, opts, name: __MODULE__) + {name, opts} = Keyword.pop(opts, :name, __MODULE__) + GenStage.start_link(__MODULE__, opts, name: name) end - def init(opts \\ [max_demand: 1]) do - {:consumer, :event_producer, subscribe_to: [{EventProducer, opts}]} + def init(opts) do + {:consumer, [], opts} end def handle_events([command], _from, state) do @@ -27,10 +27,16 @@ defmodule Radiator.Outline.EventConsumer do defp process_command(%InsertNodeCommand{payload: payload} = command) do payload |> Outline.insert_node() - |> handle_insert_result(command) + |> handle_insert_node_result(command) end - defp handle_insert_result({:ok, node}, command) do + defp process_command(%ChangeNodeContentCommand{node_id: node_id, content: content} = command) do + node_id + |> Outline.update_node_content(content) + |> handle_change_node_content_result(command) + end + + defp handle_insert_node_result({:ok, node}, command) do %NodeInsertedEvent{node: node, event_id: command.event_id} |> EventStore.persist_event() |> Dispatch.broadcast() @@ -38,9 +44,26 @@ defmodule Radiator.Outline.EventConsumer do {:ok, node} end - defp handle_insert_result({:error, _error}, _event) do + defp handle_insert_node_result({:error, _error}, _event) do # log_error_please :-) + :error + end + + def handle_change_node_content_result({:ok, node}, command) do + %NodeContentChangedEvent{node: node, event_id: command.event_id} + |> EventStore.persist_event() + |> Dispatch.broadcast() + {:ok, node} + end + + def handle_change_node_content_result({:error, :not_found}, _command) do + # log_error_please :-) + :error + end + + def handle_change_node_content_result({:error, _changeset}, _command) do + # log_error_please :-) :error end end diff --git a/lib/radiator/outline/event_producer.ex b/lib/radiator/outline/event_producer.ex index b322c8ca..b7ffd4eb 100644 --- a/lib/radiator/outline/event_producer.ex +++ b/lib/radiator/outline/event_producer.ex @@ -4,35 +4,24 @@ defmodule Radiator.Outline.EventProducer do use GenStage def start_link(opts \\ []) do - GenStage.start_link(__MODULE__, opts, name: __MODULE__) + name = Keyword.get(opts, :name, __MODULE__) + GenStage.start_link(__MODULE__, opts, name: name) end def init(_opts) do {:producer, {:queue.new(), 0}} end - def enqueue(event) do - GenStage.cast(__MODULE__, {:enqueue, event}) + def enqueue(server \\ __MODULE__, command) do + GenStage.cast(server, {:enqueue, command}) :ok end - def handle_cast({:enqueue, event}, {queue, 0}) do - queue = :queue.in(event, queue) - {:noreply, [], {queue, 0}} + def handle_cast({:enqueue, command}, state) do + {:noreply, [command], state} end - def handle_cast({:enqueue, event}, {queue, demand}) do - queue = :queue.in(event, queue) - {{:value, event}, queue} = :queue.out(queue) - {:noreply, [event], {queue, demand - 1}} - end - - def handle_demand(_incoming, {queue, demand}) do - with {item, queue} <- :queue.out(queue), - {:value, event} <- item do - {:noreply, [event], {queue, demand}} - else - _ -> {:noreply, [], {queue, demand + 1}} - end + def handle_demand(_incoming, state) do + {:noreply, [], state} end end diff --git a/lib/radiator/outline/node.ex b/lib/radiator/outline/node.ex index 827fe7e3..c6fc1008 100644 --- a/lib/radiator/outline/node.ex +++ b/lib/radiator/outline/node.ex @@ -9,7 +9,7 @@ defmodule Radiator.Outline.Node do @derive {Jason.Encoder, only: [:uuid, :content, :creator_id, :parent_id, :prev_id]} - @primary_key {:uuid, :binary_id, autogenerate: true} + @primary_key {:uuid, :binary_id, autogenerate: false} schema "outline_nodes" do field :content, :string field :creator_id, :integer @@ -32,9 +32,12 @@ defmodule Radiator.Outline.Node do """ def insert_changeset(node, attributes) do node - |> cast(attributes, [:content, :episode_id, :creator_id, :parent_id, :prev_id]) + |> cast(attributes, [:uuid, :content, :episode_id, :creator_id, :parent_id, :prev_id]) + |> put_uuid() |> update_change(:content, &trim/1) |> validate_required([:content, :episode_id]) + |> validate_format(:uuid, ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + |> unique_constraint(:uuid, name: "outline_nodes_pkey") end @doc """ @@ -54,4 +57,14 @@ defmodule Radiator.Outline.Node do defp trim(content) when is_binary(content), do: String.trim(content) defp trim(content), do: content + + defp put_uuid(%Ecto.Changeset{valid?: true} = changeset) do + if changed?(changeset, :uuid) do + changeset + else + put_change(changeset, :uuid, Ecto.UUID.generate()) + end + end + + defp put_uuid(changeset), do: changeset end diff --git a/lib/radiator_web/live/episode_live/index.ex b/lib/radiator_web/live/episode_live/index.ex index 1f2390e1..f9e9651c 100644 --- a/lib/radiator_web/live/episode_live/index.ex +++ b/lib/radiator_web/live/episode_live/index.ex @@ -3,7 +3,7 @@ defmodule RadiatorWeb.EpisodeLive.Index do alias Radiator.Outline alias Radiator.Outline.{Dispatch, NodeRepository} - alias Radiator.Outline.Event.NodeInsertedEvent + alias Radiator.Outline.Event.{NodeContentChangedEvent, NodeInsertedEvent} alias Radiator.Podcast @impl true @@ -46,21 +46,25 @@ defmodule RadiatorWeb.EpisodeLive.Index do |> reply(:noreply) end - def handle_event("create_node", %{"temp_id" => temp_id} = params, socket) do + def handle_event("create_node", %{"event_id" => event_id} = params, socket) do user = socket.assigns.current_user episode = socket.assigns.selected_episode attrs = Map.merge(params, %{"creator_id" => user.id, "episode_id" => episode.id}) - Dispatch.insert_node(attrs, user.id, temp_id) - socket |> reply(:noreply) + + Dispatch.insert_node(attrs, user.id, event_id) + + socket + |> reply(:noreply) end - def handle_event("update_node", %{"uuid" => uuid} = params, socket) do - attrs = Map.merge(%{"parent_id" => nil, "prev_id" => nil}, params) + def handle_event( + "update_node", + %{"uuid" => uuid, "content" => content, "event_id" => event_id}, + socket + ) do + user = socket.assigns.current_user - case NodeRepository.get_node(uuid) do - nil -> nil - node -> Outline.update_node_content(node, attrs, socket.id) - end + Dispatch.change_node_content(uuid, content, user.id, event_id) socket |> reply(:noreply) @@ -77,24 +81,27 @@ defmodule RadiatorWeb.EpisodeLive.Index do end @impl true - def handle_info( - %NodeInsertedEvent{event_id: _event_id, node: node}, - socket - ) do + def handle_info(%NodeInsertedEvent{node: node, event_id: event_id}, socket) do + socket + |> push_event("insert", %{node: node, event_id: event_id}) + |> reply(:noreply) + end + + def handle_info(%NodeContentChangedEvent{node: node, event_id: event_id}, socket) do socket - |> push_event("insert", node) + |> push_event("update", %{node: node, event_id: event_id}) |> reply(:noreply) end def handle_info({:update, node}, socket) do socket - |> push_event("update", node) + |> push_event("update", %{node: node}) |> reply(:noreply) end def handle_info({:delete, node}, socket) do socket - |> push_event("delete", node) + |> push_event("delete", %{node: node}) |> reply(:noreply) end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 89df6abe..1779be32 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -26,40 +26,56 @@ alias Radiator.{Accounts, Outline, Podcast} Podcast.create_episode(%{title: "current episode", show_id: show.id}) {:ok, node1} = - Outline.create_node(%{content: "Node 1", episode_id: current_episode.id}) + Outline.insert_node(%{"content" => "Node 1", "episode_id" => current_episode.id}) {:ok, node2} = - Outline.create_node(%{content: "Node 2", episode_id: current_episode.id, prev_id: node1.uuid}) + Outline.insert_node(%{ + "content" => "Node 2", + "episode_id" => current_episode.id, + "prev_id" => node1.uuid + }) {:ok, node3} = - Outline.create_node(%{content: "Node 3", episode_id: current_episode.id, prev_id: node2.uuid}) + Outline.insert_node(%{ + "content" => "Node 3", + "episode_id" => current_episode.id, + "prev_id" => node2.uuid + }) {:ok, _node4} = - Outline.create_node(%{content: "Node 4", episode_id: current_episode.id, prev_id: node3.uuid}) + Outline.insert_node(%{ + "content" => "Node 4", + "episode_id" => current_episode.id, + "prev_id" => node3.uuid + }) {:ok, node21} = - Outline.create_node(%{ - content: "Node 2.1", - episode_id: current_episode.id, - parent_id: node2.uuid + Outline.insert_node(%{ + "content" => "Node 2.1", + "episode_id" => current_episode.id, + "parent_id" => node2.uuid }) {:ok, _node22} = - Outline.create_node(%{content: "Node 2.2", episode_id: current_episode.id, prev_id: node21.uuid}) + Outline.insert_node(%{ + "content" => "Node 2.2", + "episode_id" => current_episode.id, + "prev_id" => node21.uuid + }) {:ok, node211} = - Outline.create_node(%{ - content: "Node 2.1.1", - episode_id: current_episode.id, - parent_id: node21.uuid + Outline.insert_node(%{ + "content" => "Node 2.1.1", + "episode_id" => current_episode.id, + "parent_id" => node21.uuid }) {:ok, _node212} = - Outline.create_node(%{ - content: "Node 2.1.2", - episode_id: current_episode.id, - prev_id: node211.uuid + Outline.insert_node(%{ + "content" => "Node 2.1.2", + "episode_id" => current_episode.id, + "prev_id" => node211.uuid }) -{:ok, past_parent_node} = - Outline.create_node(%{content: "Old Content", episode_id: past_episode.id}) +{:ok, _past_parent_node} = + Outline.insert_node(%{"content" => "Old Content", "episode_id" => past_episode.id}) diff --git a/test/radiator/outline/dispatch_test.exs b/test/radiator/outline/dispatch_test.exs index 550a088c..710f690d 100644 --- a/test/radiator/outline/dispatch_test.exs +++ b/test/radiator/outline/dispatch_test.exs @@ -1,34 +1,55 @@ defmodule Radiator.Outline.DispatchTest do + @moduledoc false + use Radiator.DataCase import Radiator.AccountsFixtures import Radiator.PodcastFixtures # alias Radiator.Outline - alias Radiator.Outline.Dispatch - alias Radiator.Outline.Node + # alias Radiator.Outline.Dispatch + # alias Radiator.Outline.Node describe "outline dispatch" do setup do - %{episode: episode_fixture()} - end - - test "insert_node does WHAT?", %{episode: episode} do user = user_fixture() + episode = episode_fixture() - node = %Node{episode_id: episode.id, content: "something very special 1!1"} - attributes = Map.from_struct(node) + %{user: user, episode: episode} + end - event_id = Ecto.UUID.generate() + test "insert_node does persist node", %{user: _user, episode: _episode} do + # attributes = %{"content" => "something very special 1!1", "episode_id" => episode.id} + # event_id = Ecto.UUID.generate() - Dispatch.insert_node(attributes, user.id, event_id) + # Dispatch.insert_node(attributes, user.id, event_id) # _inserted_node = - # Outline.list_nodes() + # Outline.NodeRepository.list_nodes_by_episode(episode.id) # |> Enum.find(&(&1.content == "something very special 1!1")) # assert inserted_node.episode_id == node.episode_id # assert inserted_node.content == node.content + + #### + + # command = %Radiator.Outline.Command.InsertNodeCommand{ + # event_id: event_id, + # user_id: user.id, + # payload: attributes + # } + + # consumer_pid = Process.whereis(Radiator.Outline.EventConsumer) + # producer_pid = Process.whereis(Radiator.Outline.EventProducer) + + # send(consumer_pid, [command]) + # send(producer_pid, {:enqueue, command}) + + # Process.send(consumer_pid, [command], []) + # Process.send(producer_pid, [command], []) + + # assert_receive({_, ^consumer_pid, _}, 5000) + # assert_receive({_, ^producer_pid, _}, 5000) end end end diff --git a/test/radiator/outline/event_consumer_test.exs b/test/radiator/outline/event_consumer_test.exs index 74e6f857..5324e8b8 100644 --- a/test/radiator/outline/event_consumer_test.exs +++ b/test/radiator/outline/event_consumer_test.exs @@ -1,4 +1,60 @@ defmodule Radiator.Outline.EventConsumerTest do + use Radiator.DataCase + + alias Radiator.AccountsFixtures + alias Radiator.Outline.Command + alias Radiator.Outline.Command.InsertNodeCommand + alias Radiator.Outline.Dispatch + alias Radiator.Outline.Event.NodeInsertedEvent + alias Radiator.Outline.EventConsumer + alias Radiator.Outline.EventProducer + alias Radiator.PodcastFixtures + describe "handle_events/2" do + test "insert_node" do + episode = PodcastFixtures.episode_fixture() + + attributes = %{ + "title" => "Node Title", + "content" => "Node Content", + "episode_id" => episode.id + } + + user_id = "user_id" + event_id = "event_id" + + command = Command.build("insert_node", attributes, user_id, event_id) + EventConsumer.handle_events([command], 0, nil) + # assert a node has been created + # assert an event has been created (and be stored) + end + + test "handles previously enqueued events" do + producer = start_supervised!({EventProducer, name: TestEventProducer}) + + episode = PodcastFixtures.episode_fixture() + user = AccountsFixtures.user_fixture() + event_id = Ecto.UUID.generate() + + command = %InsertNodeCommand{ + event_id: event_id, + user_id: user.id, + payload: %{ + "title" => "Node Title", + "content" => "Node Content", + "episode_id" => episode.id + } + } + + Dispatch.subscribe(episode.id) + + EventProducer.enqueue(producer, command) + + start_supervised!( + {EventConsumer, name: TestEventConsumer, subscribe_to: [{producer, max_demand: 1}]} + ) + + assert_receive(%NodeInsertedEvent{}, 1000) + end end end diff --git a/test/radiator/outline/node_test.exs b/test/radiator/outline/node_test.exs new file mode 100644 index 00000000..8f6f4a54 --- /dev/null +++ b/test/radiator/outline/node_test.exs @@ -0,0 +1,50 @@ +defmodule Radiator.Outline.NodeTest do + @moduledoc false + + use Radiator.DataCase + + alias Radiator.Outline.Node + alias Radiator.Outline.NodeRepository + alias Radiator.PodcastFixtures + + describe "insert_changeset/2" do + test "accepts a UUID" do + episode = PodcastFixtures.episode_fixture() + + uuid = Ecto.UUID.generate() + + attributes = %{ + "uuid" => uuid, + "episode_id" => episode.id, + "content" => "Node Content" + } + + assert {:ok, %Node{uuid: ^uuid}} = NodeRepository.create_node(attributes) + end + + test "generates a UUID when none is provided" do + episode = PodcastFixtures.episode_fixture() + + attributes = %{ + "episode_id" => episode.id, + "content" => "Node Content" + } + + assert {:ok, %Node{uuid: uuid}} = NodeRepository.create_node(attributes) + assert {:ok, _} = Ecto.UUID.cast(uuid) + end + + test "validates the UUID" do + episode = PodcastFixtures.episode_fixture() + + attributes = %{ + "uuid" => "not-a-uuid", + "episode_id" => episode.id, + "content" => "Node Content" + } + + assert {:error, changeset} = NodeRepository.create_node(attributes) + assert [uuid: {"has invalid format", _}] = changeset.errors + end + end +end diff --git a/test/radiator/outline_test.exs b/test/radiator/outline_test.exs index f1cce3a6..e9b6561a 100644 --- a/test/radiator/outline_test.exs +++ b/test/radiator/outline_test.exs @@ -12,15 +12,18 @@ defmodule Radiator.OutlineTest do describe "update_node_content/2" do test "with valid data updates the node" do node = node_fixture() - update_attrs = %{"content" => "some updated content"} + updated_content = "some updated content" - assert {:ok, %Node{} = node} = Outline.update_node_content(node, update_attrs) - assert node.content == "some updated content" + assert {:ok, %Node{} = node} = Outline.update_node_content(node.uuid, updated_content) + assert node.content == updated_content end test "with invalid data returns error changeset" do node = node_fixture() - assert {:error, %Ecto.Changeset{}} = Outline.update_node_content(node, %{"content" => nil}) + + assert {:error, %Ecto.Changeset{}} = + Outline.update_node_content(node.uuid, %{"content" => nil}) + assert node == NodeRepository.get_node!(node.uuid) end end diff --git a/test/radiator_web/live/episode_live_test.exs b/test/radiator_web/live/episode_live_test.exs index 2456846c..d4bd4d1b 100644 --- a/test/radiator_web/live/episode_live_test.exs +++ b/test/radiator_web/live/episode_live_test.exs @@ -6,6 +6,7 @@ defmodule RadiatorWeb.EpisodeLiveTest do import Radiator.PodcastFixtures import Radiator.OutlineFixtures + alias Radiator.Outline.Node alias Radiator.Outline.NodeRepository describe "Episode page is restricted" do @@ -67,22 +68,45 @@ defmodule RadiatorWeb.EpisodeLiveTest do test "insert a new node", %{conn: conn, show: show} do {:ok, live, _html} = live(conn, ~p"/admin/podcast/#{show.id}") - {:ok, _other_live, _html} = live(conn, ~p"/admin/podcast/#{show.id}") + {:ok, other_live, _html} = live(conn, ~p"/admin/podcast/#{show.id}") - temp_id = "f894d2ed-9447-4eef-8c31-fc52372b3bbe" - params = %{"temp_id" => temp_id, "content" => "new node temp content"} + uuid = Ecto.UUID.generate() + event_id = Ecto.UUID.generate() + params = %{"uuid" => uuid, "event_id" => event_id, "content" => "new node temp content"} assert live |> render_hook(:create_node, params) - node = - NodeRepository.list_nodes() - |> Enum.find(&(&1.content == "new node temp content")) + node = NodeRepository.get_node!(uuid) + assert_push_event(live, "insert", %{node: ^node, event_id: ^event_id}) + assert_push_event(other_live, "insert", %{node: ^node, event_id: ^event_id}) + end - _node_with_temp_id = Map.put(node, :temp_id, temp_id) + test "receive node inserted event after inserting a node", %{conn: conn, show: show} do + {:ok, live, _html} = live(conn, ~p"/admin/podcast/#{show.id}") - # assert handle info was called with temp id, js got push event with node - # FIXME: assert_reply(live, ^node_with_temp_id) - # FIXME: assert_push_event(other_live, "insert", ^node) + event_id1 = Ecto.UUID.generate() + event_id2 = Ecto.UUID.generate() + content1 = Ecto.UUID.generate() + content2 = Ecto.UUID.generate() + params1 = %{"event_id" => event_id1, "content" => content1} + params2 = %{"event_id" => event_id2, "content" => content2} + + assert live |> render_hook(:create_node, params1) + assert live |> render_hook(:create_node, params2) + + assert_push_event( + live, + "insert", + %{node: %Node{content: ^content1}, event_id: ^event_id1}, + 1000 + ) + + assert_push_event( + live, + "insert", + %{node: %Node{content: ^content2}, event_id: ^event_id2}, + 1000 + ) end test "update node", %{conn: conn, show: show, episode: episode} do @@ -91,19 +115,16 @@ defmodule RadiatorWeb.EpisodeLiveTest do node = node_fixture(%{episode_id: episode.id}) - params = - node - |> Map.from_struct() - |> Map.put(:content, "update node content") + update_attrs = %{ + content: "update node content", + event_id: Ecto.UUID.generate() + } + params = node |> Map.from_struct() |> Map.merge(update_attrs) assert live |> render_hook(:update_node, params) - updated_node = - NodeRepository.list_nodes() - |> Enum.find(&(&1.content == "update node content")) - - assert updated_node.uuid == params.uuid - assert updated_node.content == params.content + updated_node = NodeRepository.get_node!(node.uuid) + assert updated_node.content == update_attrs.content end test "delete node", %{conn: conn, show: show, episode: episode} do