From 10b773f6f020f1c59ee8fbf698333c9ffd57e853 Mon Sep 17 00:00:00 2001 From: Renato Massaro Date: Sat, 10 Feb 2018 10:18:26 -0200 Subject: [PATCH] Use idempotent setup system on Story steps --- events.json | 2 +- lib/core/listener/model/owner.ex | 1 + lib/hell/hack.ex | 3 +- lib/software/make/file.ex | 16 ++- lib/software/make/pftp.ex | 8 +- lib/software/model/public_ftp.ex | 8 ++ lib/software/query/public_ftp.ex | 12 +- lib/story/action/flow/story.ex | 2 +- lib/story/event/handler/story.ex | 19 ++-- lib/story/make/story.ex | 4 +- lib/story/mission/tutorial/steps.ex | 79 +++++++++---- lib/story/model/step.ex | 12 +- lib/story/model/step/macros.ex | 107 ++++++++++++------ lib/story/model/step/macros/setup.ex | 58 ++++++++++ lib/story/model/step/steppable.ex | 95 +++++++++------- ..._fix_cascade_relationship_on_pftp_file.exs | 20 ++++ .../account/topics/email_reply_test.exs | 2 +- test/features/storyline/flow_test.exs | 30 ++++- test/story/model/macro_test.exs | 42 +++++++ test/story/quests/tutorial_test.exs | 31 +++++ test/support/channel/macros.ex | 4 +- test/support/story/fake_steps.ex | 50 ++++++-- test/support/story/helper.ex | 2 +- test/support/story/macros.ex | 27 +++++ test/support/story/setup.ex | 27 +++-- test/support/story/setup/manager.ex | 21 +++- test/support/story/vars.ex | 24 ++++ 27 files changed, 552 insertions(+), 154 deletions(-) create mode 100644 lib/story/model/step/macros/setup.ex create mode 100644 priv/repo/software/migrations/20180210115107_fix_cascade_relationship_on_pftp_file.exs create mode 100644 test/story/quests/tutorial_test.exs create mode 100644 test/support/story/vars.ex diff --git a/events.json b/events.json index 247a75ad..87254673 100644 --- a/events.json +++ b/events.json @@ -196,7 +196,7 @@ "filters": ["Account.Created"], "emits": [] }, - "DownloadCrackerPublicFTP": { + "DownloadCracker": { "filters": ["File.Downloaded"], "emits": [] } diff --git a/lib/core/listener/model/owner.ex b/lib/core/listener/model/owner.ex index 84fd24d8..10f98850 100644 --- a/lib/core/listener/model/owner.ex +++ b/lib/core/listener/model/owner.ex @@ -45,6 +45,7 @@ defmodule Helix.Core.Listener.Model.Owner do %__MODULE__{} |> cast(params, @creation_fields) |> validate_required(@required_fields) + |> unique_constraint(:owner_id_object_id_event_subscriber) end defmodule Query do diff --git a/lib/hell/hack.ex b/lib/hell/hack.ex index f976ea3a..62585147 100644 --- a/lib/hell/hack.ex +++ b/lib/hell/hack.ex @@ -53,10 +53,11 @@ defmodule HELL.Hack.Experience do {:render, 3} ], "Elixir.Helix.Story.Model.Steppable" => [ + {:start, 2}, {:setup, 2}, {:handle_event, 3}, {:complete, 1}, - {:fail, 1}, + {:restart, 3}, {:next_step, 1}, {:get_contact, 1}, {:format_meta, 1}, diff --git a/lib/software/make/file.ex b/lib/software/make/file.ex index 3b7aa21e..e4280ea5 100644 --- a/lib/software/make/file.ex +++ b/lib/software/make/file.ex @@ -7,6 +7,8 @@ defmodule Helix.Software.Make.File do alias Helix.Software.Model.Software alias Helix.Software.Model.Storage + alias Helix.Software.Event.File.Added, as: FileAddedEvent + @type modules :: cracker_modules @type cracker_modules :: %{bruteforce: version, overflow: version} @@ -20,27 +22,27 @@ defmodule Helix.Software.Make.File do Generates a cracker. """ @spec cracker(file_parent, cracker_modules, data) :: - {:ok, File.t_of_type(:cracker), %{}} + {:ok, File.t_of_type(:cracker), %{}, []} def cracker(parent, modules, data \\ %{}), do: file(parent, :cracker, modules, data) @spec cracker!(file_parent, cracker_modules, data) :: File.t_of_type(:cracker) def cracker!(parent, modules, data \\ %{}) do - {:ok, file, _} = cracker(parent, modules, data) + {:ok, file, _, _} = cracker(parent, modules, data) file end @spec file(file_parent, Software.type, modules, data) :: - {:ok, File.t, %{}} + {:ok, File.t, %{}, []} defp file(server = %Server{}, type, modules, data) do server |> CacheQuery.from_server_get_storages!() |> List.first() - |> file(type, modules, data) + |> file(type, modules, data, server.server_id) end - defp file(storage_id = %Storage.ID{}, type, modules, data) do + defp file(storage_id = %Storage.ID{}, type, modules, data, server_id) do path = Map.get(data, :path, File.Default.path()) modules = format_modules(type, modules) @@ -56,7 +58,9 @@ defmodule Helix.Software.Make.File do {:ok, file} = FileAction.create(params, modules) - {:ok, file, %{}} + event = FileAddedEvent.new(file, server_id) + + {:ok, file, %{}, [event]} end @spec format_modules(Software.type, modules) :: diff --git a/lib/software/make/pftp.ex b/lib/software/make/pftp.ex index a6dd3893..ae1abe0c 100644 --- a/lib/software/make/pftp.ex +++ b/lib/software/make/pftp.ex @@ -6,18 +6,18 @@ defmodule Helix.Software.Make.PFTP do alias Helix.Software.Model.PublicFTP @spec server(Server.t) :: - {:ok, PublicFTP.t, %{}} + {:ok, PublicFTP.t, %{}, []} def server(server = %Server{}) do {:ok, pftp} = PublicFTPFlow.enable_server(server) - {:ok, pftp, %{}} + {:ok, pftp, %{}, []} end @spec add_file(File.t, PublicFTP.t) :: - {:ok, PublicFTP.File.t, %{}} + {:ok, PublicFTP.File.t, %{}, []} def add_file(file = %File{}, pftp = %PublicFTP{}) do {:ok, pftp_file} = PublicFTPFlow.add_file(pftp, file) - {:ok, pftp_file, %{}} + {:ok, pftp_file, %{}, []} end end diff --git a/lib/software/model/public_ftp.ex b/lib/software/model/public_ftp.ex index 1ee745ad..427efdcd 100644 --- a/lib/software/model/public_ftp.ex +++ b/lib/software/model/public_ftp.ex @@ -79,6 +79,14 @@ defmodule Helix.Software.Model.PublicFTP do def disable_server(changeset = %Changeset{}), do: put_change(changeset, :is_active, false) + @spec is_active?(t) :: + boolean + @doc """ + Verifies whether the given server is active (enabled) or not. + """ + def is_active?(pftp = %__MODULE__{is_active: is_active?}), + do: is_active? + @spec create_changeset(creation_params) :: changeset defp create_changeset(params) do diff --git a/lib/software/query/public_ftp.ex b/lib/software/query/public_ftp.ex index abdcf553..7937569f 100644 --- a/lib/software/query/public_ftp.ex +++ b/lib/software/query/public_ftp.ex @@ -5,7 +5,7 @@ defmodule Helix.Software.Query.PublicFTP do alias Helix.Software.Model.File alias Helix.Software.Model.PublicFTP - @spec fetch_file(File.t) :: + @spec fetch_file(File.idt) :: PublicFTP.File.t | nil @doc """ @@ -15,9 +15,11 @@ defmodule Helix.Software.Query.PublicFTP do - The PublicFTP server is active. """ def fetch_file(file = %File{}), - do: PublicFTPInternal.fetch_file(file.file_id) + do: fetch_file(file.file_id) + def fetch_file(file_id = %File.ID{}), + do: PublicFTPInternal.fetch_file(file_id) - @spec fetch_server(Server.t) :: + @spec fetch_server(Server.idt) :: PublicFTP.t | nil @doc """ @@ -26,7 +28,9 @@ defmodule Helix.Software.Query.PublicFTP do Disabled/inactive servers are returned as well. """ def fetch_server(server = %Server{}), - do: PublicFTPInternal.fetch(server.server_id) + do: fetch_server(server.server_id) + def fetch_server(server_id = %Server.ID{}), + do: PublicFTPInternal.fetch(server_id) @spec list_files(Server.idt) :: [File.t] diff --git a/lib/story/action/flow/story.ex b/lib/story/action/flow/story.ex index 287eadf2..67c1ae6f 100644 --- a/lib/story/action/flow/story.ex +++ b/lib/story/action/flow/story.ex @@ -30,7 +30,7 @@ defmodule Helix.Story.Action.Flow.Story do with \ {:ok, _} <- ContextFlow.setup(entity), {:ok, story_step} <- StoryAction.proceed_step(first_step), - {:ok, _, events} <- Steppable.setup(first_step, nil), + {:ok, _, events} <- Steppable.start(first_step, nil), on_success(fn -> Event.emit(events, from: relay) end) do {:ok, story_step} diff --git a/lib/story/event/handler/story.ex b/lib/story/event/handler/story.ex index 590c15c7..ae849b75 100644 --- a/lib/story/event/handler/story.ex +++ b/lib/story/event/handler/story.ex @@ -30,7 +30,7 @@ defmodule Helix.Story.Event.Handler.Story do Emits: - Events returned by `Steppable` methods - StepProceededEvent.t when action is :complete - - StepFailedEvent.t, StepRestartedEvent.t when action is :fail + - StepFailedEvent.t, StepRestartedEvent.t when action is :restart """ def event_handler(event) do with \ @@ -71,7 +71,10 @@ defmodule Helix.Story.Event.Handler.Story do with \ {action, step, events} <- Steppable.handle_event(step, step.event, step.meta), - on_success(fn -> Event.emit(events, from: step.event) end), + + # HACK: Workaround for HELF #29 + # on_success(fn -> Event.emit(events, from: step.event) end), + Event.emit(events, from: step.event), handle_action(action, step) do :ok @@ -79,7 +82,7 @@ defmodule Helix.Story.Event.Handler.Story do end end - @spec handle_action(Steppable.actions, Step.t) :: + @spec handle_action(Step.callback_action, Step.t) :: term docp """ When a step requests to be completed, we'll call `Steppable.complete/1`, @@ -100,8 +103,8 @@ defmodule Helix.Story.Event.Handler.Story do If the request is to fail/abort an step, we'll call `Steppable.fail/1`, and then handle the failure with `fail_step/1` """ - defp handle_action(:fail, step) do - with {:ok, step, events} <- Steppable.fail(step) do + defp handle_action({:restart, reason, checkpoint}, step) do + with {:ok, step, events} <- Steppable.restart(step, reason, checkpoint) do Event.emit(events, from: step.event) hespawn fn -> @@ -121,8 +124,8 @@ defmodule Helix.Story.Event.Handler.Story do docp """ Updates the database, so that the player gets moved to the next step. - This is where we call next step's `Steppable.setup`, as well as the - `StepProceededEvent` is sent to the client + This is where we call next step's `Steppable.start`, as well as make sure the + `StepProceededEvent` is sent to the client. Emits: StepProceededEvent.t """ @@ -135,7 +138,7 @@ defmodule Helix.Story.Event.Handler.Story do # /\ Proceeds player to the next step # Generate next step data/meta - {:ok, next_step, events} <- Steppable.setup(next_step, prev_step), + {:ok, next_step, events} <- Steppable.start(next_step, prev_step), Event.emit(events, from: prev_step.event), # Update step meta diff --git a/lib/story/make/story.ex b/lib/story/make/story.ex index a9eadf04..9c17f31a 100644 --- a/lib/story/make/story.ex +++ b/lib/story/make/story.ex @@ -13,7 +13,7 @@ defmodule Helix.Story.Make.Story do @typep char_related :: %{entity: Entity.t} @spec char(Network.id) :: - {:ok, Server.t, char_related} + {:ok, Server.t, char_related, []} def char(network_id = %Network.ID{}) do network = NetworkQuery.fetch(network_id) net_data = @@ -28,7 +28,7 @@ defmodule Helix.Story.Make.Story do {:ok, entity, _} <- MakeEntity.entity(npc), {:ok, server, _} <- MakeServer.npc(entity, net_data) do - {:ok, server, %{entity: entity}} + {:ok, server, %{entity: entity}, []} end end end diff --git a/lib/story/mission/tutorial/steps.ex b/lib/story/mission/tutorial/steps.ex index 1c491a66..b8fc05ae 100644 --- a/lib/story/mission/tutorial/steps.ex +++ b/lib/story/mission/tutorial/steps.ex @@ -6,14 +6,22 @@ defmodule Helix.Story.Mission.Tutorial do step SetupPc do - email "welcome_pc_setup", + email "welcome", reply: "back_thanks" on_reply "back_thanks", + send: "watchiadoing" + + email "watchiadoing", + reply: "hell_yeah" + + on_reply "hell_yeah", :complete - def setup(step, _) do - e1 = send_email step, "welcome_pc_setup" + empty_setup() + + def start(step, _) do + e1 = send_email step, "welcome" {:ok, step, e1} end @@ -22,10 +30,10 @@ defmodule Helix.Story.Mission.Tutorial do {:ok, step, []} end - next_step Helix.Story.Mission.Tutorial.DownloadCrackerPublicFtp + next_step Helix.Story.Mission.Tutorial.DownloadCracker end - step DownloadCrackerPublicFtp do + step DownloadCracker do alias Helix.Server.Model.Server alias Helix.Server.Query.Server, as: ServerQuery @@ -37,7 +45,7 @@ defmodule Helix.Story.Mission.Tutorial do alias Helix.Software.Make.File, as: MakeFile alias Helix.Software.Make.PFTP, as: MakePFTP - email "download_cracker_public_ftp", + email "download_cracker1", reply: ["more_info"], locked: ["sure"] @@ -49,17 +57,38 @@ defmodule Helix.Story.Mission.Tutorial do send: "give_more_info" def setup(step, _) do - {:ok, server, %{entity: entity}} = StoryMake.char(step.manager.network_id) - - ContextAction.save(step.entity_id, @contact, :server_id, server.server_id) - ContextAction.save(step.entity_id, @contact, :entity_id, entity.entity_id) + # Create the underlying character (@contact) and its server + {:ok, server, %{entity: entity}, e1} = + setup_once :char, {step.entity_id, @contact} do + result = {:ok, server, %{entity: entity}, events} = + StoryMake.char(step.manager.network_id) + + ContextAction.save( + step.entity_id, @contact, :server_id, server.server_id + ) + ContextAction.save( + step.entity_id, @contact, :entity_id, entity.entity_id + ) + + result + end # Create the Cracker the player is supposed to download - cracker = MakeFile.cracker!(server, %{bruteforce: 10, overflow: 10}) - - # Enable a PFTP server and put the cracker in it - {:ok, pftp, _} = MakePFTP.server(server) - MakePFTP.add_file(cracker, pftp) + {:ok, cracker, _, e2} = + setup_once :file, step.meta[:cracker_id] do + MakeFile.cracker(server, %{bruteforce: 10, overflow: 10}) + end + + # Enable the PFTP server and put the cracker in it + {:ok, pftp, _, e3} = + setup_once :pftp_server, server do + MakePFTP.server(server) + end + + {:ok, _, _, e4} = + setup_once :pftp_file, cracker do + MakePFTP.add_file(cracker, pftp) + end ip = ServerQuery.get_ip(server, step.manager.network_id) @@ -71,15 +100,25 @@ defmodule Helix.Story.Mission.Tutorial do } # Callbacks + hespawn fn -> - # React to the moment the cracker is downloaded - story_listen cracker.file_id, FileDownloadedEvent, do: :complete + # React to the moment the cracker is downloaded + story_listen cracker.file_id, FileDownloadedEvent, do: :complete + end - e1 = send_email step, "download_cracker_public_ftp", %{ip: ip} + events = e1 ++ e2 ++ e3 ++ e4 + + {meta, %{}, events} + end + + def start(step, prev_step) do + {meta, _, e1} = setup(step, prev_step) + + e2 = send_email step, "download_cracker1", %{ip: meta.ip} step = %{step|meta: meta} - {:ok, step, e1} + {:ok, step, e1 ++ e2} end format_meta do @@ -94,6 +133,6 @@ defmodule Helix.Story.Mission.Tutorial do {:ok, step, []} end - next_step __MODULE__ + next_step Helix.Story.Mission.Tutorial.SetupPc end end diff --git a/lib/story/model/step.ex b/lib/story/model/step.ex index bf4dfa35..30075112 100644 --- a/lib/story/model/step.ex +++ b/lib/story/model/step.ex @@ -49,11 +49,19 @@ defmodule Helix.Story.Model.Step do @type contact :: Constant.t @type contact_id :: contact + @typedoc """ + The `callback_action` type lists all possible actions that may be applied to + a step. Notably, one of them must be returned by `Steppable.handle_event/3`, + but it's also used in other contexts, including on `StepActionRequestedEvent`. + + The action will be interpreted and applied at the StoryEventHandler. + + Note that `:restart` also includes metadata (`reason` and `checkpoint`). + """ @type callback_action :: :complete - | :fail - | :regenerate | :noop + | {:restart, reason :: atom, checkpoint :: email_id} @spec new(t, Event.t) :: t diff --git a/lib/story/model/step/macros.ex b/lib/story/model/step/macros.ex index 6dfb5b17..29678a4c 100644 --- a/lib/story/model/step/macros.ex +++ b/lib/story/model/step/macros.ex @@ -30,6 +30,8 @@ defmodule Helix.Story.Model.Step.Macros do defimpl Helix.Story.Model.Steppable do @moduledoc false + import HELL.Macros + alias Helix.Event alias Helix.Story.Make.Story, as: StoryMake @@ -40,11 +42,11 @@ defmodule Helix.Story.Model.Step.Macros do unquote(block) - # Most steps do not have a "fail" option. Those who do must manually - # implement this protocol function. + # Most steps do not have a "restart" option. Those who do must + # manually implement this protocol function. @doc false - def fail(_step), - do: raise "Undefined fail handler at #{inspect unquote(__MODULE__)}" + def restart(_step, _, _), + do: raise "Undefined restart handler at #{inspect unquote(__MODULE__)}" # Catch-all for unhandled events, otherwise any unexpected event would # thrown an exception here. @@ -60,23 +62,19 @@ defmodule Helix.Story.Model.Step.Macros do def get_contact(_), do: @contact - # Unlocked replies only + @spec get_replies(Step.t, Step.email_id) :: + [Step.reply_id] @doc false + # Unlocked replies only def get_replies(_step, email_id) do - case Map.get(@emails, email_id) do - email = %{} -> - email.replies - nil -> - [] + with email = %{} <- Map.get(@emails, email_id, []) do + email.replies end end - @spec handle_callback(Step.callback_action, Entity.id, Step.contact) :: - {:ok, [Event.t]} - defp handle_callback(action, entity_id, contact) when not is_tuple(action), - do: handle_callback({action, []}, entity_id, contact) - - @spec handle_callback({Step.callback_action, [Event.t]}, Entity.id, Step.contact) :: + @spec handle_callback( + {Step.callback_action, [Event.t]}, Entity.id, Step.contact) + :: {:ok, [Event.t]} defp handle_callback({action, events}, entity_id, contact_id) do request_action = @@ -87,17 +85,7 @@ defmodule Helix.Story.Model.Step.Macros do @doc false callback :callback_complete do - :complete - end - - @doc false - callback :callback_fail do - :fail - end - - @doc false - callback :callback_regenerate do - :regenerate + {:complete, []} end end end @@ -116,9 +104,9 @@ defmodule Helix.Story.Model.Step.Macros do do quote do - def unquote(name)(var!(event) = unquote(event), m = unquote(meta)) do - step_entity_id = m["step_entity_id"] |> Entity.ID.cast!() - step_contact_id = m["step_contact_id"] |> String.to_existing_atom() + def unquote(name)(var!(event) = unquote(event), meta = unquote(meta)) do + step_entity_id = meta["step_entity_id"] |> Entity.ID.cast!() + step_contact_id = meta["step_contact_id"] |> String.to_existing_atom() var!(event) # Mark as used @@ -129,6 +117,9 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Executes a predefined callback (currently only `:complete` is supported). + """ defmacro story_listen(element_id, events, do: action) do quote do @@ -145,6 +136,7 @@ defmodule Helix.Story.Model.Step.Macros do It's a wrapper for `Core.Listener`. """ defmacro story_listen(element_id, events, callback) do + # Import `Helix.Core.Listener` only once within the Step context (ENV) macro = has_macro?(__CALLER__, Helix.Core.Listener) import_block = macro && [] || quote(do: import Helix.Core.Listener) @@ -163,12 +155,18 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Formats the step metadata, automatically handling empty maps or atomizing + existing map keys. + """ defmacro format_meta(do: block) do quote do + @doc false def format_meta(%{meta: empty_map}) when empty_map == %{}, do: %{} + @doc false def format_meta(%{meta: meta}) do var!(meta) = HELL.MapUtils.atomize_keys(meta) unquote(block) @@ -177,6 +175,11 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Public interface that should be used by the step to point to the next one. + + Steps are linked lists. Mind == blown. + """ defmacro next_step(next_step_module) do quote do # unless Code.ensure_compiled?(unquote(next_step_module)) do @@ -190,7 +193,7 @@ defmodule Helix.Story.Model.Step.Macros do # I don't want neither 3 or 4. Waiting for a cool hack on 1 or 2. @doc """ - Returns the next step module name. + Returns the next step module name (#{inspect unquote(next_step_module)}). """ def next_step(_), do: Helix.Story.Model.Step.get_name(unquote(next_step_module)) @@ -254,9 +257,9 @@ defmodule Helix.Story.Model.Step.Macros do {:complete, step, []} end - [fail: true] -> + [restart: true, reason: reason, checkpoint: checkpoint] -> quote do - {:fail, step, []} + {{:restart, unquote(reason), unquote(checkpoint)}, step, []} end end ) @@ -265,6 +268,9 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Interface used to declare what should happen when `reply_id` is received. + """ defmacro on_reply(reply_id, opts) do # Emails that can receive this reply emails = get_emails(__CALLER__) @@ -288,7 +294,7 @@ defmodule Helix.Story.Model.Step.Macros do end @doc """ - Below macro is required so the elixir compiler does not complain about the + This macro is required so the elixir compiler does not complain about the module attribute not being used. """ defmacro contact(contact_name) do @@ -297,6 +303,39 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Helper (syntactic sugar) for steps that do not generate any data. + """ + defmacro empty_setup do + quote do + + @doc false + def setup(_, _) do + nil + end + + end + end + + alias Helix.Story.Model.Step.Macros.Setup, as: StepSetup + + defmacro setup_once(object, identifier, do: block), + do: do_setup_once(object, identifier, [], block) + defmacro setup_once(object, identifier, opts, do: block), + do: do_setup_once(object, identifier, opts, block) + + defp do_setup_once(object, id, opts, block) do + fun_name = Utils.concat_atom(:find_, object) + + quote do + result = apply(StepSetup, unquote(fun_name), [unquote(id), unquote(opts)]) + + with nil <- result do + unquote(block) + end + end + end + @spec get_contact(String.t | Constant.t | nil, module :: term) :: contact :: Step.contact @doc """ diff --git a/lib/story/model/step/macros/setup.ex b/lib/story/model/step/macros/setup.ex new file mode 100644 index 00000000..1ba97221 --- /dev/null +++ b/lib/story/model/step/macros/setup.ex @@ -0,0 +1,58 @@ +defmodule Helix.Story.Model.Step.Macros.Setup do + + alias HELL.Utils + alias Helix.Entity.Model.Entity + alias Helix.Entity.Query.Entity, as: EntityQuery + alias Helix.Server.Model.Server + alias Helix.Server.Query.Server, as: ServerQuery + alias Helix.Software.Model.File + alias Helix.Software.Model.PublicFTP + alias Helix.Software.Query.File, as: FileQuery + alias Helix.Software.Query.PublicFTP, as: PublicFTPQuery + alias Helix.Story.Model.Step + alias Helix.Story.Query.Context, as: ContextQuery + + @spec find_char({Entity.id, Step.contact_id}, []) :: + {:ok, Server.t, %{entity: Entity.t}, []} + | nil + def find_char({entity_id = %Entity.ID{}, contact_id}, _opts) do + with \ + context = %{} <- ContextQuery.get(entity_id, contact_id), + server = %{} <- ServerQuery.fetch(context.server_id), + entity = %{} <- EntityQuery.fetch(context.entity_id) + do + {:ok, server, %{entity: entity}, []} + end + end + + def find_file(nil, _), + do: nil + def find_file(file_id = %File.ID{}, _opts) do + with file = %{} <- FileQuery.fetch(file_id) do + {:ok, file, %{}, []} + end + end + + def find_pftp_server(nil, _opts), + do: nil + def find_pftp_server(server = %Server{}, opts), + do: find_pftp_server(server.server_id, opts) + def find_pftp_server(server_id = %Server.ID{}, _opts) do + with \ + pftp = %{} <- PublicFTPQuery.fetch_server(server_id), + true <- PublicFTP.is_active?(pftp) || nil + do + {:ok, pftp, %{}, []} + end + end + + def find_pftp_file(nil, _opts), + do: nil + def find_pftp_file(file = %File{}, opts), + do: find_pftp_file(file.file_id, opts) + def find_pftp_file(file_id = %File.ID{}, _opts) do + with pftp_file = %{} <- PublicFTPQuery.fetch_file(file_id) do + {:ok, pftp_file, %{}, []} + end + end +end diff --git a/lib/story/model/step/steppable.ex b/lib/story/model/step/steppable.ex index 7cccccc1..747be899 100644 --- a/lib/story/model/step/steppable.ex +++ b/lib/story/model/step/steppable.ex @@ -65,9 +65,24 @@ defprotocol Helix.Story.Model.Steppable do email. If `:complete` is given, Helix will call the `complete/1` function. The `do` block means an arbitrary command will be executed. - ## Setting up a new step with `setup/2` + ## Idempotent context creation with `setup/2` - The `setup/2` function is called when the previous step was completed, and + `setup/2` is responsible for generating the entire step environment in an + idempotent fashion. This is important because, in the case of a step restart, + fresh data *may* need to be regenerated. + + The list of events should include all events originated by the `setup/2` + method. So, for instance, if during the `setup/2` function we generate a file + and a log, both corresponding events should be passed upstream, so they can be + emitted by the StoryHandler. + + (Note that whenever someone requests a step restart, they are responsible for + cleaning up any stale/corrupt/invalid data. `setup/2` only creates data. See + the "Restarting a step" section for more information). + + ## Setting up a new step with `start/2` + + The `start/2` function is called when the previous step was completed, and this step will be marked as the player's current step. It receives both the current and the previous step struct (in most cases you can ignore the previous step). @@ -76,14 +91,9 @@ defprotocol Helix.Story.Model.Steppable do one of `:ok` | `:error` (where `:error` means something really bad happened internally). - It's on the `setup/2` function that a step should generate and prepare its - environment, as well as send its first email to the user (applicable in most - cases). - - Note that the list of events should include all events originated by the setup - method. So, for instance, if during the `setup/2` function we generate a file - and a log, both corresponding events should be passed upstream, so they can be - emitted by the StoryHandler. + `start/2` will use the `setup/2` method to generate and prepare the step data + and environment. Once this is done, it will also send the email to the user + (applicable in most cases). ## Reacting to arbitrary events with `handle_event` @@ -97,14 +107,16 @@ defprotocol Helix.Story.Model.Steppable do :noop message, meaning that that event should be ignored. Pay attention to the return types of `handle_event`! The return signature is - `{action, new_step [events]}`, where action may be one of: + `{action, new_step, [events]}`, where action may be one of: - :noop - Do nothing after `handle_event` is called. - :complete - Mark the step as completed by calling `complete/1` - - :fail - Mark the step as failed by calling `fail/1` + - {:restart, reason, checkpoint} - Mark the step as restarted by calling + `restart/3`. `reason` is used to notify the player why the step got + restarted, and `checkpoint` points to the last email that we'll rollback to. The `new_step` will be passed to the next functions if applicable (complete - or fail). Any events on the list of events will always be emitted, even if + or restart). Any events on the list of events will always be emitted, even if `:noop` is the returned action. ## Completing a step with `complete` @@ -125,6 +137,10 @@ defprotocol Helix.Story.Model.Steppable do In order to do so, simply use the `next_step` macro, passing the module name as argument, like `next_step Helix.Story.Mission.MissionName.StepName` + ## Restarting a step with `restart/3` + + TODO DOCME + ## Example A working example can be seen at `lib/story/mission/tutorial/steps.ex` @@ -133,18 +149,8 @@ defprotocol Helix.Story.Model.Steppable do alias Helix.Event alias Helix.Story.Model.Step - @typedoc """ - The `actions` type lists all possible actions returned by `handle_event/3`, - which will be interpreted by the StepFlow implemented at StoryEventHandler. - """ - @type actions :: - :noop - | :complete - | :fail - @typep generic_step :: Step.t - - @spec setup(cur_step :: generic_step, prev_step :: generic_step | nil) :: - {:ok | :error, generic_step, [Event.t]} + @spec start(cur_step :: Step.t, prev_step :: Step.t | nil) :: + {:ok | :error, Step.t, [Event.t]} @doc """ Function called when the previous step was completed. It has the purpose of setting up the new step, preparing its environment and generating any objects @@ -158,10 +164,13 @@ defprotocol Helix.Story.Model.Steppable do environment generation. It should be logged and debug thoroughly, since no errors should happen during this step. """ + def start(step, previous_step) + + # TODO DOCME def setup(step, previous_step) - @spec handle_event(generic_step, Event.t, Step.meta) :: - {actions, generic_step, [Event.t]} + @spec handle_event(Step.t, Event.t, Step.meta) :: + {Step.callback_action, Step.t, [Event.t]} @doc """ Generic filtering of events. Any event will be pattern-matched against the implementations of `handle_event` of the given Step. @@ -170,38 +179,38 @@ defprotocol Helix.Story.Model.Steppable do If the returned action is `:noop`, the StepHandler will stop the flow. If `:complete` is returned, then `complete/1` will be called. On the other hand, - if `:fail` is returned, `fail/1` will be called. + if `{:restart, reason, checkpoint}` is returned, `restart/3` will be called. - Note that if a step is fallible, it must implement `fail/1` + Note that if a step is "restartable", it must explicitly implement `restart/3` """ def handle_event(step, event, meta) - @spec complete(generic_step) :: - {:ok | :error, generic_step, [Event.t]} + @spec complete(Step.t) :: + {:ok | :error, Step.t, [Event.t]} @doc """ Method called when the Step has been marked for completion. This can only happen when a specific event was matched and the returned action was `:complete`. - Similar to `setup`, `:error` should only be returned in ugly cases, in which + Similar to `start/2`, `:error` should only be returned in ugly cases, in which the error reason should be thoroughly logged and debugged. """ def complete(step) - @spec fail(generic_step) :: - {:ok | :error, generic_step, [Event.t]} + @spec restart(Step.t, reason :: atom, checkpoint :: Step.email_id) :: + {:ok | :error, Step.t, [Event.t]} @doc """ - Method used when the step fails. Note that most steps are not fallible, and - as such implementing this function is not necessary. + Method used when the step is restarted. Note that most steps are not + restartable, and as such implementing this function is not always necessary. - However, if you want this step to be fallible, it must implement `fail/1`. + However, if you want it to be restartable, it must be explicitly implemented. - `fail/1` is only called when a specific `handle_event` returned `:fail` as - action. + `restart/3` is only called when a specific `handle_event` returned + `{:restart, reason, checkpoint}` as the requested action. """ - def fail(step) + def restart(step, reason, checkpoint) - @spec next_step(generic_step) :: + @spec next_step(Step.t) :: Step.step_name @doc """ Points to the next step. @@ -224,7 +233,7 @@ defprotocol Helix.Story.Model.Steppable do """ def get_contact(step) - @spec format_meta(generic_step) :: + @spec format_meta(Step.t) :: Step.meta @doc """ Converts back the step's metadata to Helix internal data structure. Since the @@ -235,7 +244,7 @@ defprotocol Helix.Story.Model.Steppable do """ def format_meta(step) - @spec get_replies(generic_step, Step.email_id) :: + @spec get_replies(Step.t, Step.email_id) :: [Step.reply_id] @doc """ Returns all possible unlocked replies of an email. diff --git a/priv/repo/software/migrations/20180210115107_fix_cascade_relationship_on_pftp_file.exs b/priv/repo/software/migrations/20180210115107_fix_cascade_relationship_on_pftp_file.exs new file mode 100644 index 00000000..ae211875 --- /dev/null +++ b/priv/repo/software/migrations/20180210115107_fix_cascade_relationship_on_pftp_file.exs @@ -0,0 +1,20 @@ +defmodule Helix.Software.Repo.Migrations.FixCascadeRelationshipOnPFTPFile do + use Ecto.Migration + + def change do + # Remove old FK + execute """ + ALTER TABLE pftp_files + DROP CONSTRAINT pftp_files_file_id_fkey + """ + + # Add new FK with ON DELETE CASCADE (as opposed to SET NULL) + execute """ + ALTER TABLE pftp_files + ADD CONSTRAINT pftp_files_file_id_fkey + FOREIGN KEY (file_id) + REFERENCES files(file_id) + ON DELETE CASCADE; + """ + end +end diff --git a/test/account/websocket/channel/account/topics/email_reply_test.exs b/test/account/websocket/channel/account/topics/email_reply_test.exs index ee77a68c..14a9222f 100644 --- a/test/account/websocket/channel/account/topics/email_reply_test.exs +++ b/test/account/websocket/channel/account/topics/email_reply_test.exs @@ -94,7 +94,7 @@ defmodule Helix.Account.Websocket.Channel.Account.Topics.EmailReplyTest do {socket, %{entity_id: entity_id}} = ChannelSetup.join_account() # Remove any existing step - StoryHelper.remove_existing_step(entity_id) + StoryHelper.remove_existing_steps(entity_id) params = %{ diff --git a/test/features/storyline/flow_test.exs b/test/features/storyline/flow_test.exs index f84b351b..56ea824f 100644 --- a/test/features/storyline/flow_test.exs +++ b/test/features/storyline/flow_test.exs @@ -14,6 +14,7 @@ defmodule Helix.Test.Features.Storyline.Flow do alias Helix.Test.Entity.Helper, as: EntityHelper alias Helix.Test.Process.TOPHelper alias Helix.Test.Server.Helper, as: ServerHelper + alias Helix.Test.Story.Vars, as: StoryVars @moduletag :feature @@ -30,15 +31,33 @@ defmodule Helix.Test.Features.Storyline.Flow do account_id: account.account_id, socket: server_socket ) + # Inherit storyline variables + s = StoryVars.vars() + # Player is on mission assert [%{object: cur_step}] = StoryQuery.get_steps(entity_id) assert cur_step.name == Step.first_step_name() - # We'll now complete the first step by replying to the email + # We'll now progress on the first step by replying to the email params = %{ - "reply_id" => "back_thanks", - "contact_id" => to_string(cur_step.contact) + "reply_id" => s.step.setup_pc.msg2, + "contact_id" => s.contact.friend + } + ref = push account_socket, "email.reply", params + assert_reply ref, :ok, _, timeout() + + # Contact just replied with the next message + [story_email_sent] = wait_events [:story_email_sent] + + assert_email \ + story_email_sent, s.step.setup_pc.msg3, s.step.setup_pc.msg4, cur_step + + # Now we'll reply back and finally proceed to the next step + params = + %{ + "reply_id" => s.step.setup_pc.msg4, + "contact_id" => s.contact.friend } ref = push account_socket, "email.reply", params assert_reply ref, :ok, _, timeout(:slow) @@ -46,7 +65,8 @@ defmodule Helix.Test.Features.Storyline.Flow do # Now we've proceeded to the next step. [story_step_proceeded] = wait_events [:story_step_proceeded] - assert_transition story_step_proceeded, "setup_pc", "download_cracker" + assert_transition \ + story_step_proceeded, s.step.setup_pc.name, s.step.setup_pc.next # Fetch setup data %{object: cur_step} = StoryQuery.fetch_step(entity_id, cur_step.contact) @@ -75,7 +95,7 @@ defmodule Helix.Test.Features.Storyline.Flow do [story_step_proceeded] = wait_events [:story_step_proceeded] assert_transition \ - story_step_proceeded, "download_cracker", "download_cracker" + story_step_proceeded, "download_cracker", s.step.setup_pc.name end end end diff --git a/test/story/model/macro_test.exs b/test/story/model/macro_test.exs index ba11d2de..4cf4a91b 100644 --- a/test/story/model/macro_test.exs +++ b/test/story/model/macro_test.exs @@ -4,6 +4,7 @@ defmodule Helix.Story.Model.MacroTest do import ExUnit.CaptureLog + alias Helix.Software.Internal.File, as: FileInternal alias Helix.Story.Event.Email.Sent, as: StoryEmailSentEvent alias Helix.Story.Model.Steppable @@ -51,4 +52,45 @@ defmodule Helix.Story.Model.MacroTest do Steppable.handle_event(step, invalid_reply_event, %{}) end end + + describe "setup_once" do + test "handles hit and misses" do + # NOTE: The step used here is simply for dummy data; that's why this test + # will probably be repeated on the same step's tests + + {step, _} = StorySetup.step( + name: :tutorial@download_cracker, + meta: %{}, + ready: true + ) + + # Generate for the first time (100% misses) + assert {meta, _, _events} = Steppable.setup(step, %{}) + + assert meta.cracker_id + assert meta.server_id + assert meta.ip + + # Try again, now redoing everything (for idempotency, should be 100% hits) + step = %{step| meta: meta} + assert {meta2, _, _events} = Steppable.setup(step, %{}) + assert meta2 == meta + + # Now we'll nuke the `cracker_id`. + meta.cracker_id + |> FileInternal.fetch() + |> FileInternal.delete() + + # As a result, a new `cracker_id` should be generated, but everything else + # should be the same + assert {meta3, _, _events} = Steppable.setup(step, %{}) + + # New cracker was generated + refute meta3.cracker_id == meta.cracker_id + + # But the other stuff is the same + assert meta3.server_id == meta.server_id + assert meta3.ip == meta.ip + end + end end diff --git a/test/story/quests/tutorial_test.exs b/test/story/quests/tutorial_test.exs new file mode 100644 index 00000000..e090cf4c --- /dev/null +++ b/test/story/quests/tutorial_test.exs @@ -0,0 +1,31 @@ +defmodule Helix.Story.Quests.Tutorial.DownloadCrackerTest do + + use Helix.Test.Case.Integration + + alias Helix.Story.Model.Steppable + + alias Helix.Test.Server.Setup, as: ServerSetup + alias Helix.Test.Story.Helper, as: StoryHelper + alias Helix.Test.Story.Setup, as: StorySetup + + describe "setup/2" do + test "creates the context/environment; idempotent" do + {step, _} = StorySetup.step( + name: :tutorial@download_cracker, + meta: %{}, + ready: true + ) + + assert {meta, _, _events} = Steppable.setup(step, %{}) + + assert meta.server_id + assert meta.cracker_id + assert meta.ip + + # Ensure idempotency + step = %{step| meta: meta} + assert {meta2, _, _events} = Steppable.setup(step, %{}) + assert meta2 == meta + end + end +end diff --git a/test/support/channel/macros.ex b/test/support/channel/macros.ex index c6c3c2f8..37ee490d 100644 --- a/test/support/channel/macros.ex +++ b/test/support/channel/macros.ex @@ -56,9 +56,9 @@ defmodule Helix.Test.Channel.Macros do @doc """ Debugger/helper that lists all events in the mailbox. """ - defmacro list_events do + defmacro list_events(timeout \\ quote(do: 50)) do quote do - unquote(wait_all()) + unquote(wait_all(timeout)) end end end diff --git a/test/support/story/fake_steps.ex b/test/support/story/fake_steps.ex index e4f5124e..3ec956d2 100644 --- a/test/support/story/fake_steps.ex +++ b/test/support/story/fake_steps.ex @@ -44,7 +44,9 @@ defmodule Helix.Story.Mission.FakeSteps do alias Helix.Entity.Model.Entity - def setup(step, _), + empty_setup() + + def start(step, _), do: {:ok, step, []} def complete(step), @@ -68,15 +70,23 @@ defmodule Helix.Story.Mission.FakeSteps do end step TestSimple do - def setup(step, _), + + empty_setup() + + def start(step, _), do: {:ok, step, []} + def complete(step), do: {:ok, step, []} + next_step __MODULE__ end step TestOne do - def setup(step, _), + + empty_setup() + + def start(step, _), do: {:ok, step, []} def complete(step), @@ -89,7 +99,10 @@ defmodule Helix.Story.Mission.FakeSteps do end step TestTwo do - def setup(step, _), + + empty_setup() + + def start(step, _), do: {:ok, step, []} def complete(step), @@ -102,7 +115,10 @@ defmodule Helix.Story.Mission.FakeSteps do end step TestCounter do - def setup(step, _), + + empty_setup() + + def start(step, _), do: {:ok, step, []} def complete(step), @@ -138,7 +154,9 @@ defmodule Helix.Story.Mission.FakeSteps do on_reply "reply_to_e3", :complete - def setup(step, _) do + empty_setup() + + def start(step, _) do send_email step, "e1" {:ok, step, []} end @@ -157,10 +175,15 @@ defmodule Helix.Story.Mission.FakeContactOne do contact :contact_one step TestSimple do - def setup(step, _), + + empty_setup() + + def start(step, _), do: {:ok, step, []} + def complete(step), do: {:ok, step, []} + next_step __MODULE__ end end @@ -172,10 +195,15 @@ defmodule Helix.Story.Mission.FakeContactTwo do contact :contact_two step TestSimple do - def setup(step, _), + + empty_setup() + + def start(step, _), do: {:ok, step, []} - def complete(step), - do: {:ok, step, []} - next_step __MODULE__ + + def complete(step), + do: {:ok, step, []} + + next_step __MODULE__ end end diff --git a/test/support/story/helper.ex b/test/support/story/helper.ex index 5c156038..f0fcb47d 100644 --- a/test/support/story/helper.ex +++ b/test/support/story/helper.ex @@ -4,7 +4,7 @@ defmodule Helix.Test.Story.Helper do alias Helix.Story.Model.Story alias Helix.Story.Repo, as: StoryRepo - def remove_existing_step(entity_id) do + def remove_existing_steps(entity_id) do entity_id |> StepInternal.get_steps() |> Enum.each(&(StoryRepo.delete(&1.entry))) diff --git a/test/support/story/macros.ex b/test/support/story/macros.ex index 387290b1..161153ca 100644 --- a/test/support/story/macros.ex +++ b/test/support/story/macros.ex @@ -1,15 +1,42 @@ defmodule Helix.Test.Story.Macros do + alias HELL.Utils + @doc """ Asserts that the story has transitioned from `expected_from` to `expected_to` """ defmacro assert_transition(event, expected_from, expected_to) do quote do + event_from = unquote(event).data.previous_step event_to = unquote(event).data.next_step assert String.contains?(event_from, unquote(expected_from)) assert String.contains?(event_to, unquote(expected_to)) + + end + end + + @doc """ + Asserts that the received email is the one expected, including the allowed + replies and the contact/step are the same as before. + """ + defmacro assert_email(event, expected_email, replies, step) do + quote do + + event_data = unquote(event).data + replies = Utils.ensure_list(unquote(replies)) + + assert unquote(event).event == "story_email_sent" + assert event_data.email_id == unquote(expected_email) + + Enum.each(replies, fn reply -> + assert Enum.member?(event_data.replies, reply) + end) + + assert to_string(unquote(step).contact) == event_data.contact_id + assert to_string(unquote(step).name) == event_data.step + end end end diff --git a/test/support/story/setup.ex b/test/support/story/setup.ex index d8db81bb..4dcefac0 100644 --- a/test/support/story/setup.ex +++ b/test/support/story/setup.ex @@ -35,7 +35,21 @@ defmodule Helix.Test.Story.Setup do end entity_id = Keyword.get(opts, :entity_id, Entity.ID.generate()) - manager = Keyword.get(opts, :manager, get_or_create_manager(entity_id)) + + manager = + if opts[:ready] do + StorySetup.Manager.manager!(real_network: true, entity_id: entity_id) + else + Keyword.get(opts, :manager, get_or_create_manager(entity_id)) + end + + # If the user requested a "ready" step, we'll make sure to create a valid + # context for the entity. "ready" steps also have the manager ensured, + # as well as valid underlying data (except for the entity_id, which is + # generated randomly, but may be overridden) + if opts[:ready] do + StorySetup.Context.context(entity_id: entity_id) + end step = Step.fetch(name, entity_id, meta, manager) @@ -53,15 +67,14 @@ defmodule Helix.Test.Story.Setup do {_, related = %{step: step}} = fake_story_step(opts) - # Save the step on DB and run its `setup` - {:ok, _} = StoryAction.proceed_step(step) - {:ok, _, _} = Steppable.setup(step, %{}) - - manager = get_or_create_manager(step.entity_id) - # Update step with the newly created manager + manager = get_or_create_manager(step.entity_id) step = %{step| manager: manager} + # Save the step on DB and `start/2` it + {:ok, _} = StoryAction.proceed_step(step) + {:ok, _, _} = Steppable.start(step, %{}) + related = related |> put_in([:manager], manager) diff --git a/test/support/story/setup/manager.ex b/test/support/story/setup/manager.ex index 14fa8b5c..902c413d 100644 --- a/test/support/story/setup/manager.ex +++ b/test/support/story/setup/manager.ex @@ -6,6 +6,7 @@ defmodule Helix.Test.Story.Setup.Manager do alias Helix.Test.Entity.Setup, as: EntitySetup alias Helix.Test.Network.Helper, as: NetworkHelper + alias Helix.Test.Network.Setup, as: NetworkSetup alias Helix.Test.Server.Setup, as: ServerSetup @doc """ @@ -17,15 +18,33 @@ defmodule Helix.Test.Story.Setup.Manager do {inserted, related} end + def manager!(opts \\ []) do + {manager, _} = manager(opts) + manager + end + @doc """ - entity_id: Set entity whose Manager belongs to. Defaults to fake entity - server_id: Set server ID. Defaults to fake server - network_id: Set network ID. Defaults to fake network. + - real_network: Whether to use a real network. Defaults to false. """ def fake_manager(opts \\ []) do entity_id = Keyword.get(opts, :entity_id, EntitySetup.id()) server_id = Keyword.get(opts, :server_id, ServerSetup.id()) - network_id = Keyword.get(opts, :network_id, NetworkHelper.id()) + + network_id = + cond do + opts[:real_network] -> + {network, _} = NetworkSetup.network(type: :story) + network.network_id + + opts[:network_id] -> + opts[:network_id] + + true -> + NetworkHelper.id() + end manager = %Story.Manager{ diff --git a/test/support/story/vars.ex b/test/support/story/vars.ex new file mode 100644 index 00000000..28038a00 --- /dev/null +++ b/test/support/story/vars.ex @@ -0,0 +1,24 @@ +defmodule Helix.Test.Story.Vars do + @moduledoc """ + This helper will inject (in a non-higienic way!) + """ + + @vars %{ + contact: %{ + friend: "friend" + }, + step: %{ + setup_pc: %{ + name: "setup_pc", + next: "download_cracker", + msg1: "welcome", + msg2: "back_thanks", + msg3: "watchiadoing", + msg4: "hell_yeah", + } + } + } + + def vars, + do: @vars +end