From 78f6c11b30788fb60f6712adaa36477a5a3e6df8 Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Sat, 4 Apr 2026 20:33:51 +0200 Subject: [PATCH] Rename to aether plugin and add /aether timeline integration --- assets/css/{my_plugin.css => aether.css} | 4 +- assets/js/{my_plugin.js => aether.js} | 8 +- lib/{my_plugin => aether}/application.ex | 6 +- lib/{my_plugin => aether}/plugin.ex | 58 ++- .../live/home_live.ex | 6 +- lib/aether_web/live/timeline_live.ex | 366 ++++++++++++++++++ lib/tribes/plugins/aether/plugin.ex | 5 + manifest.json | 13 +- mix.exs | 8 +- scripts/rename.sh | 33 +- test/aether/host_integration_test.exs | 27 ++ test/{my_plugin => aether}/manifest_test.exs | 27 +- test/{my_plugin => aether}/plugin_test.exs | 6 +- test/contract_test.exs | 10 +- 14 files changed, 515 insertions(+), 62 deletions(-) rename assets/css/{my_plugin.css => aether.css} (80%) rename assets/js/{my_plugin.js => aether.js} (55%) rename lib/{my_plugin => aether}/application.ex (75%) rename lib/{my_plugin => aether}/plugin.ex (54%) rename lib/{my_plugin_web => aether_web}/live/home_live.ex (85%) create mode 100644 lib/aether_web/live/timeline_live.ex create mode 100644 lib/tribes/plugins/aether/plugin.ex create mode 100644 test/aether/host_integration_test.exs rename test/{my_plugin => aether}/manifest_test.exs (70%) rename test/{my_plugin => aether}/plugin_test.exs (93%) diff --git a/assets/css/my_plugin.css b/assets/css/aether.css similarity index 80% rename from assets/css/my_plugin.css rename to assets/css/aether.css index aa1e98a..777966b 100644 --- a/assets/css/my_plugin.css +++ b/assets/css/aether.css @@ -1,13 +1,13 @@ /* * Plugin CSS entry point. * - * Served at /plugins-assets/my_plugin/my_plugin.css + * Served at /plugins-assets/aether/aether.css * and included in the page layout if declared in manifest.json assets.global_css. * * Prefix all selectors with your plugin name to avoid collisions * with host or other plugin styles. */ -.my-plugin { +.aether { /* Plugin-scoped styles go here */ } diff --git a/assets/js/my_plugin.js b/assets/js/aether.js similarity index 55% rename from assets/js/my_plugin.js rename to assets/js/aether.js index c8116f1..3c50d99 100644 --- a/assets/js/my_plugin.js +++ b/assets/js/aether.js @@ -1,15 +1,15 @@ // Plugin JavaScript entry point. // -// This file is served by the host at /plugins-assets/my_plugin/my_plugin.js +// This file is served by the host at /plugins-assets/aether/aether.js // and included in the page layout if declared in manifest.json assets.global_js. // // To register LiveView hooks: // // window.TribesPluginHooks = window.TribesPluginHooks || {}; -// window.TribesPluginHooks["MyPluginHook"] = { +// window.TribesPluginHooks["AetherHook"] = { // mounted() { -// console.log("MyPlugin hook mounted"); +// console.log("Aether hook mounted"); // } // }; -console.log("my_plugin loaded"); +console.log("aether loaded"); diff --git a/lib/my_plugin/application.ex b/lib/aether/application.ex similarity index 75% rename from lib/my_plugin/application.ex rename to lib/aether/application.ex index 5553a9b..408b576 100644 --- a/lib/my_plugin/application.ex +++ b/lib/aether/application.ex @@ -1,4 +1,4 @@ -defmodule MyPlugin.Application do +defmodule Aether.Application do @moduledoc """ OTP Application for this plugin. @@ -12,10 +12,10 @@ defmodule MyPlugin.Application do def start(_type, _args) do children = [ # Add your supervised processes here, e.g.: - # {MyPlugin.Worker, []} + # {Aether.Worker, []} ] - opts = [strategy: :one_for_one, name: MyPlugin.Supervisor] + opts = [strategy: :one_for_one, name: Aether.Supervisor] Supervisor.start_link(children, opts) end end diff --git a/lib/my_plugin/plugin.ex b/lib/aether/plugin.ex similarity index 54% rename from lib/my_plugin/plugin.ex rename to lib/aether/plugin.ex index f0310ff..0d3ee57 100644 --- a/lib/my_plugin/plugin.ex +++ b/lib/aether/plugin.ex @@ -1,4 +1,4 @@ -defmodule MyPlugin.Plugin do +defmodule Aether.Plugin do @moduledoc """ Tribes plugin entry point. @@ -10,7 +10,7 @@ defmodule MyPlugin.Plugin do # Once Tribes.Plugin.Base is available, replace the manual implementation # with: # - # use Tribes.Plugin.Base, otp_app: :my_plugin + # use Tribes.Plugin.Base, otp_app: :aether # # and override only register/1. @@ -27,8 +27,8 @@ defmodule MyPlugin.Plugin do enhances_with: manifest["enhances_with"] || [], nav_items: [ %{ - label: "My Plugin", - path: "/plugins/my_plugin", + label: "Aether", + path: "/aether", icon: nil, requires: [], order: 50 @@ -36,8 +36,8 @@ defmodule MyPlugin.Plugin do ], pages: [ %{ - path: "/plugins/my_plugin", - live_view: MyPluginWeb.HomeLive, + path: "/aether", + live_view: AetherWeb.TimelineLive, layout: nil } ], @@ -58,24 +58,44 @@ defmodule MyPlugin.Plugin do end defp manifest_path do - # In a release, manifest.json sits alongside ebin/ in the plugin directory. - # In dev mode (path dep), it's at the project root. - case :code.priv_dir(:my_plugin) do - {:error, :bad_name} -> - # Dev mode fallback: relative to project root - Path.join(__DIR__, "../../../manifest.json") |> Path.expand() + project_manifest = Path.join(__DIR__, "../../manifest.json") |> Path.expand() - priv_dir -> - priv_dir |> to_string() |> Path.join("../manifest.json") |> Path.expand() - end + candidates = + case :code.priv_dir(:aether) do + {:error, :bad_name} -> + [project_manifest] + + priv_dir -> + priv_dir = to_string(priv_dir) + + [ + Path.join(priv_dir, "../manifest.json") |> Path.expand(), + project_manifest + ] + end + + first_existing_path(candidates) || project_manifest end defp migrations_path(manifest) do if manifest["migrations"] do - case :code.priv_dir(:my_plugin) do - {:error, :bad_name} -> nil - priv_dir -> priv_dir |> to_string() |> Path.join("repo/migrations") - end + candidates = + case :code.priv_dir(:aether) do + {:error, :bad_name} -> + [Path.join(__DIR__, "../../priv/repo/migrations") |> Path.expand()] + + priv_dir -> + [ + Path.join(to_string(priv_dir), "repo/migrations") |> Path.expand(), + Path.join(__DIR__, "../../priv/repo/migrations") |> Path.expand() + ] + end + + first_existing_path(candidates) end end + + defp first_existing_path(paths) do + Enum.find(paths, &File.exists?/1) + end end diff --git a/lib/my_plugin_web/live/home_live.ex b/lib/aether_web/live/home_live.ex similarity index 85% rename from lib/my_plugin_web/live/home_live.ex rename to lib/aether_web/live/home_live.ex index 8130455..c2e503f 100644 --- a/lib/my_plugin_web/live/home_live.ex +++ b/lib/aether_web/live/home_live.ex @@ -1,9 +1,9 @@ -defmodule MyPluginWeb.HomeLive do +defmodule AetherWeb.HomeLive do @moduledoc """ Example LiveView page for the plugin. This page is registered in the plugin spec and mounted by the host - at /plugins/my_plugin. + at /plugins/aether. """ # In dev mode (plugin loaded as path dep), you can use host macros: @@ -22,7 +22,7 @@ defmodule MyPluginWeb.HomeLive do

My Plugin

This is a Tribes plugin. Edit this page in - lib/my_plugin_web/live/home_live.ex. + lib/aether_web/live/home_live.ex.

""" diff --git a/lib/aether_web/live/timeline_live.ex b/lib/aether_web/live/timeline_live.ex new file mode 100644 index 0000000..6532023 --- /dev/null +++ b/lib/aether_web/live/timeline_live.ex @@ -0,0 +1,366 @@ +defmodule AetherWeb.TimelineLive do + use TribesWeb, :live_view + + on_mount({TribesWeb.LiveUserAuth, :live_user_optional}) + + alias Tribes.Communities + alias Tribes.Communities.Tribe + alias Tribes.Nostr + + @impl true + def mount(params, session, socket) do + tribe_id = resolve_tribe_id(params, session) + scope = load_scope(socket.assigns.current_user, tribe_id) + + {:ok, notes} = Nostr.list_notes(limit: 100) + notes = filter_notes(notes, scope) + profile_cache = load_profile_cache(notes) + note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new() + + socket = + socket + |> assign(:page_title, gettext("Aether")) + |> assign(:current_scope, socket.assigns[:current_scope]) + |> assign(:tribe_id, tribe_id) + |> assign(:tribe, scope.tribe) + |> assign(:can_view_tribe?, scope.can_view?) + |> assign(:is_tribe_member?, scope.member?) + |> assign(:tribe_member_pubkeys, scope.member_pubkeys) + |> assign(:profile_cache, profile_cache) + |> assign(:note_ids, note_ids) + |> assign(:note_count, MapSet.size(note_ids)) + |> assign(:form, to_form(%{"content" => ""}, as: :note)) + |> assign(:join_form, to_form(%{"invite_code" => ""}, as: :join)) + |> assign(:subscription_ref, nil) + |> stream_configure(:notes, dom_id: &("note-" <> &1["id"])) + |> stream(:notes, notes) + + if connected?(socket) do + {:ok, ref} = Nostr.subscribe_notes(self(), "aether", limit: 100) + {:ok, assign(socket, :subscription_ref, ref)} + else + {:ok, socket} + end + end + + @impl true + def handle_event("join_tribe", %{"join" => %{"invite_code" => invite_code}}, socket) do + cond do + is_nil(socket.assigns.current_user) -> + {:noreply, put_flash(socket, :error, gettext("Sign in to join tribes"))} + + is_nil(socket.assigns.tribe) -> + {:noreply, put_flash(socket, :error, gettext("No tribe selected"))} + + true -> + opts = + case String.trim(invite_code || "") do + "" -> [] + code -> [invite_code: code] + end + + case Communities.join_tribe(socket.assigns.current_user, socket.assigns.tribe.id, opts) do + {:ok, _membership} -> + socket = refresh_scope(socket) + + {:noreply, + socket + |> put_flash(:info, gettext("Joined tribe")) + |> assign(:join_form, to_form(%{"invite_code" => ""}, as: :join))} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, gettext("Unable to join tribe"))} + end + end + end + + def handle_event("post", %{"note" => %{"content" => content}}, socket) do + content = String.trim(content) + + cond do + socket.assigns[:current_user] == nil -> + {:noreply, put_flash(socket, :error, gettext("Sign in to post"))} + + content == "" -> + {:noreply, put_flash(socket, :error, gettext("Post cannot be empty"))} + + not can_post_to_scope?(socket) -> + {:noreply, put_flash(socket, :error, gettext("Join this tribe to post"))} + + true -> + case socket.assigns[:session_privkey] do + privkey when is_binary(privkey) -> + case Nostr.publish_note(content, socket.assigns.current_user.pubkey_hex, privkey) do + {:ok, event} -> + if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do + note_ids = MapSet.put(socket.assigns.note_ids, event["id"]) + + {:noreply, + socket + |> assign(:form, to_form(%{"content" => ""}, as: :note)) + |> assign(:note_ids, note_ids) + |> assign(:note_count, MapSet.size(note_ids)) + |> stream_insert(:notes, event, at: 0)} + else + {:noreply, + socket + |> assign(:form, to_form(%{"content" => ""}, as: :note)) + |> put_flash(:info, gettext("Posted outside current tribe scope"))} + end + + {:error, reason} -> + {:noreply, + put_flash( + socket, + :error, + gettext("Failed to publish: %{reason}", reason: inspect(reason)) + )} + end + + _other -> + {:noreply, put_flash(socket, :error, gettext("Signing key unavailable in session"))} + end + end + end + + @impl true + def handle_info({:parrhesia, :event, _ref, _sub_id, %{"kind" => 1} = event}, socket) do + if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) and + socket.assigns.can_view_tribe? do + profile_cache = maybe_put_profile(socket.assigns.profile_cache, event["pubkey"]) + + if MapSet.member?(socket.assigns.note_ids, event["id"]) do + {:noreply, assign(socket, :profile_cache, profile_cache)} + else + note_ids = MapSet.put(socket.assigns.note_ids, event["id"]) + + {:noreply, + socket + |> assign(:profile_cache, profile_cache) + |> assign(:note_ids, note_ids) + |> assign(:note_count, MapSet.size(note_ids)) + |> stream_insert(:notes, event, at: 0)} + end + else + {:noreply, socket} + end + end + + def handle_info({:parrhesia, :event, _ref, _sub_id, _event}, socket), do: {:noreply, socket} + def handle_info({:parrhesia, :eose, _ref, _sub_id}, socket), do: {:noreply, socket} + def handle_info({:parrhesia, :closed, _ref, _sub_id, _reason}, socket), do: {:noreply, socket} + + @impl true + def terminate(_reason, socket) do + if is_reference(socket.assigns[:subscription_ref]) do + Nostr.unsubscribe(socket.assigns.subscription_ref) + end + + :ok + end + + @impl true + def render(assigns) do + ~H""" + +
+
+

{timeline_title(@tribe)}

+
{gettext("%{count} notes", count: @note_count)}
+
+ + <%= if @tribe && !@is_tribe_member? do %> +
+
+

+ {gettext("You are viewing this tribe as a guest.")} +

+ <.form for={@join_form} id="join-tribe-form" phx-submit="join_tribe" class="space-y-3"> + <.input + field={@join_form[:invite_code]} + type="text" + label={gettext("Invite code (optional for public tribes)")} + /> +
+ +
+ +
+
+ <% end %> + + <%= if @current_user && (@can_view_tribe? || is_nil(@tribe)) do %> + <.form for={@form} id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm"> +
+ <.input field={@form[:content]} type="textarea" label={gettext("Post")} rows="3" /> +
+ +
+
+ + <% end %> + + <%= if !@can_view_tribe? && @tribe do %> +
+ {gettext("This tribe timeline is private until you join.")} +
+ <% end %> + +
+ + +
+
+
+ <.link navigate={~p"/u/#{note["pubkey"]}"} class="link link-hover font-medium"> + {author_label(@profile_cache, note["pubkey"])} + + + +
+

{note["content"]}

+
+
+
+
+
+ """ + end + + defp resolve_tribe_id(params, session) when is_map(params) do + case Map.get(params, "tribe_id") || Map.get(params, "id") do + tribe_id when is_binary(tribe_id) and tribe_id != "" -> + tribe_id + + _ -> + parse_tribe_id_from_plugin_path(session["plugin_path"]) + end + end + + defp resolve_tribe_id(_params, session), + do: parse_tribe_id_from_plugin_path(session["plugin_path"]) + + defp parse_tribe_id_from_plugin_path(path) when is_binary(path) do + case String.split(path, "/", trim: true) do + ["aether", tribe_id | _] -> tribe_id + _ -> nil + end + end + + defp parse_tribe_id_from_plugin_path(_path), do: nil + + defp load_scope(current_user, tribe_id) when is_binary(tribe_id) do + with {:ok, %Tribe{} = tribe} <- Communities.get_tribe(tribe_id), + {:ok, members} <- Communities.list_tribe_members_for_timeline(tribe.id) do + member_pubkeys = members |> Enum.map(& &1.user.pubkey_hex) |> MapSet.new() + + is_member = + case current_user do + nil -> false + user -> Enum.any?(members, &(&1.user_id == user.id)) + end + + can_view? = tribe.visibility == :public || is_member + + %{tribe: tribe, member_pubkeys: member_pubkeys, member?: is_member, can_view?: can_view?} + else + _ -> %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true} + end + end + + defp load_scope(_current_user, _tribe_id), + do: %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true} + + defp refresh_scope(socket) do + scope = load_scope(socket.assigns.current_user, socket.assigns.tribe_id) + + notes = + case Nostr.list_notes(limit: 100) do + {:ok, notes} -> filter_notes(notes, scope) + _ -> [] + end + + note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new() + + socket + |> assign(:tribe, scope.tribe) + |> assign(:can_view_tribe?, scope.can_view?) + |> assign(:is_tribe_member?, scope.member?) + |> assign(:tribe_member_pubkeys, scope.member_pubkeys) + |> assign(:note_ids, note_ids) + |> assign(:note_count, MapSet.size(note_ids)) + |> stream(:notes, notes, reset: true) + end + + defp can_post_to_scope?(socket) do + case socket.assigns.tribe_member_pubkeys do + nil -> true + pubkeys -> MapSet.member?(pubkeys, socket.assigns.current_user.pubkey_hex) + end + end + + defp note_in_scope?(_event, nil), do: true + + defp note_in_scope?(event, member_pubkeys) do + MapSet.member?(member_pubkeys, event["pubkey"]) + end + + defp filter_notes(_notes, %{can_view?: false}), do: [] + defp filter_notes(notes, %{member_pubkeys: nil}), do: notes + + defp filter_notes(notes, %{member_pubkeys: member_pubkeys}) do + Enum.filter(notes, ¬e_in_scope?(&1, member_pubkeys)) + end + + defp load_profile_cache(notes) do + notes + |> Enum.map(& &1["pubkey"]) + |> Enum.uniq() + |> Enum.reduce(%{}, &maybe_put_profile(&2, &1)) + end + + defp maybe_put_profile(cache, pubkey) do + if Map.has_key?(cache, pubkey) do + cache + else + case Nostr.get_profile(pubkey) do + {:ok, nil} -> Map.put(cache, pubkey, nil) + {:ok, event} -> Map.put(cache, pubkey, parse_profile(event)) + {:error, _reason} -> Map.put(cache, pubkey, nil) + end + end + end + + defp parse_profile(%{"content" => content}) when is_binary(content) do + case JSON.decode(content) do + {:ok, profile} when is_map(profile) -> profile + _other -> %{} + end + end + + defp parse_profile(_event), do: %{} + + defp author_label(profile_cache, pubkey) do + profile = Map.get(profile_cache, pubkey, %{}) || %{} + profile["name"] || "#{String.slice(pubkey, 0, 8)}…" + end + + defp timeline_title(nil), do: gettext("Global timeline") + defp timeline_title(%Tribe{name: name}), do: gettext("%{name} timeline", name: name) +end diff --git a/lib/tribes/plugins/aether/plugin.ex b/lib/tribes/plugins/aether/plugin.ex new file mode 100644 index 0000000..e1189f7 --- /dev/null +++ b/lib/tribes/plugins/aether/plugin.ex @@ -0,0 +1,5 @@ +defmodule Tribes.Plugins.Aether.Plugin do + @moduledoc false + + defdelegate register(context), to: Aether.Plugin +end diff --git a/manifest.json b/manifest.json index c4964d6..bb2c819 100644 --- a/manifest.json +++ b/manifest.json @@ -1,15 +1,16 @@ { - "name": "my_plugin", + "name": "aether", "version": "0.1.0", "description": "TODO: Describe what this plugin does", - "entry_module": "MyPlugin.Plugin", + "entry_module": "Tribes.Plugins.Aether.Plugin", "host_api": "1", - "provides": [], - "requires": ["ecto@1"], + "otp_app": "aether", + "provides": ["timeline_ui@1"], + "requires": ["ecto@1", "phoenix@1"], "enhances_with": [], "assets": { - "global_js": ["my_plugin.js"], - "global_css": ["my_plugin.css"] + "global_js": ["aether.js"], + "global_css": ["aether.css"] }, "migrations": false, "children": false diff --git a/mix.exs b/mix.exs index 5c4eb2a..1a3c3bc 100644 --- a/mix.exs +++ b/mix.exs @@ -1,9 +1,9 @@ -defmodule MyPlugin.MixProject do +defmodule Aether.MixProject do use Mix.Project def project do [ - app: :my_plugin, + app: :aether, version: "0.1.0", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), @@ -17,7 +17,7 @@ defmodule MyPlugin.MixProject do [ extra_applications: [:logger] # Uncomment if your plugin needs its own supervision tree: - # mod: {MyPlugin.Application, []} + # mod: {Aether.Application, []} ] end @@ -29,7 +29,7 @@ defmodule MyPlugin.MixProject do # Host dependency — provides Tribes.Plugin behaviour, types, and test helpers. # # For local development alongside a tribes checkout: - {:tribes, path: "../tribes", only: [:dev, :test]}, + {:tribes, path: "../tribes", runtime: false}, # # For CI or standalone development (when not co-located with tribes): # {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]}, diff --git a/scripts/rename.sh b/scripts/rename.sh index 701e381..2feba00 100755 --- a/scripts/rename.sh +++ b/scripts/rename.sh @@ -45,13 +45,32 @@ if [ -d "test/my_plugin" ]; then mv "test/my_plugin" "test/$SNAKE" fi -# Replace in all text files -find . -type f \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) -exec \ - sed -i '' \ - -e "s/my_plugin/$SNAKE/g" \ - -e "s/MyPlugin/$MODULE/g" \ - -e "s/my-plugin/$SNAKE/g" \ - {} + +# Replace in all text files (portable across GNU/BSD sed) +sed_in_place() { + file=$1 + + if sed --version >/dev/null 2>&1; then + sed -i \ + -e "s/my_plugin/$SNAKE/g" \ + -e "s/MyPlugin/$MODULE/g" \ + -e "s/my-plugin/$SNAKE/g" \ + "$file" + else + sed -i '' \ + -e "s/my_plugin/$SNAKE/g" \ + -e "s/MyPlugin/$MODULE/g" \ + -e "s/my-plugin/$SNAKE/g" \ + "$file" + fi +} + +while IFS= read -r -d '' file; do + sed_in_place "$file" +done < <( + find . -type f \ + \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) \ + -print0 +) # Rename asset files for ext in js css; do diff --git a/test/aether/host_integration_test.exs b/test/aether/host_integration_test.exs new file mode 100644 index 0000000..0a14859 --- /dev/null +++ b/test/aether/host_integration_test.exs @@ -0,0 +1,27 @@ +defmodule Aether.HostIntegrationTest do + use ExUnit.Case, async: false + + setup do + start_supervised!({Tribes.PluginRegistry, []}) + + spec = Tribes.Plugins.Aether.Plugin.register(%{pubsub: nil, repo: nil}) + :ok = Tribes.PluginRegistry.register_plugin(spec.name, spec, "/tmp/aether") + + on_exit(fn -> + :ok = Tribes.PluginRegistry.unregister_plugin(spec.name) + end) + + %{spec: spec} + end + + test "registers timeline capability and /aether route with host", %{spec: spec} do + assert spec.name == "aether" + assert %{name: "aether"} = Tribes.PluginRegistry.provider!("timeline_ui@1") + + assert {:ok, "aether", %{path: "/aether"}} = + Tribes.PluginRegistry.page_for_path("/aether/tribe-123") + + assert TribesWeb.Navigation.timeline_base_path() == "/aether" + assert TribesWeb.Navigation.timeline_path("tribe-123") == "/aether/tribe-123" + end +end diff --git a/test/my_plugin/manifest_test.exs b/test/aether/manifest_test.exs similarity index 70% rename from test/my_plugin/manifest_test.exs rename to test/aether/manifest_test.exs index d789aad..4d1a890 100644 --- a/test/my_plugin/manifest_test.exs +++ b/test/aether/manifest_test.exs @@ -1,4 +1,4 @@ -defmodule MyPlugin.ManifestTest do +defmodule Aether.ManifestTest do use ExUnit.Case, async: true @manifest_path Path.join(__DIR__, "../../manifest.json") |> Path.expand() @@ -15,7 +15,15 @@ defmodule MyPlugin.ManifestTest do end test "has required fields", %{manifest: manifest} do - required = ["name", "version", "entry_module", "host_api", "provides", "requires"] + required = [ + "name", + "version", + "entry_module", + "host_api", + "otp_app", + "provides", + "requires" + ] for field <- required do assert Map.has_key?(manifest, field), @@ -24,13 +32,18 @@ defmodule MyPlugin.ManifestTest do end test "name matches OTP app name", %{manifest: manifest} do - assert manifest["name"] == "my_plugin" + assert manifest["name"] == "aether" + assert manifest["otp_app"] == manifest["name"] end - test "entry_module is a valid Elixir module name", %{manifest: manifest} do + test "entry_module uses Tribes.Plugins namespace and Plugin suffix", %{manifest: manifest} do module_name = manifest["entry_module"] assert is_binary(module_name) - assert String.starts_with?(module_name, "Elixir.") or not String.contains?(module_name, " ") + + assert Regex.match?( + ~r/^Tribes\.Plugins\.[A-Z][A-Za-z0-9_]*(\.[A-Z][A-Za-z0-9_]*)*\.Plugin$/, + module_name + ) end test "provides contains valid capability identifiers", %{manifest: manifest} do @@ -53,7 +66,9 @@ defmodule MyPlugin.ManifestTest do test "entry_module matches actual plugin module", %{manifest: manifest} do module = String.to_atom("Elixir.#{manifest["entry_module"]}") - assert Code.ensure_loaded?(module), "entry_module #{manifest["entry_module"]} must be loadable" + + assert Code.ensure_loaded?(module), + "entry_module #{manifest["entry_module"]} must be loadable" end end end diff --git a/test/my_plugin/plugin_test.exs b/test/aether/plugin_test.exs similarity index 93% rename from test/my_plugin/plugin_test.exs rename to test/aether/plugin_test.exs index 299486a..0dd1775 100644 --- a/test/my_plugin/plugin_test.exs +++ b/test/aether/plugin_test.exs @@ -1,14 +1,14 @@ -defmodule MyPlugin.PluginTest do +defmodule Aether.PluginTest do use ExUnit.Case, async: true describe "register/1" do setup do context = %{pubsub: nil, repo: nil} - %{spec: MyPlugin.Plugin.register(context)} + %{spec: Aether.Plugin.register(context)} end test "returns plugin name and version", %{spec: spec} do - assert spec.name == "my_plugin" + assert spec.name == "aether" assert is_binary(spec.version) end diff --git a/test/contract_test.exs b/test/contract_test.exs index 1038b2a..db231ef 100644 --- a/test/contract_test.exs +++ b/test/contract_test.exs @@ -1,4 +1,4 @@ -defmodule MyPlugin.ContractTest do +defmodule Aether.ContractTest do @moduledoc """ Contract compliance tests. @@ -6,10 +6,10 @@ defmodule MyPlugin.ContractTest do When Tribes.Plugin.ContractTest is available (host dep loaded), replace this file with: - defmodule MyPlugin.ContractTest do + defmodule Aether.ContractTest do use Tribes.Plugin.ContractTest, - plugin: MyPlugin.Plugin, - otp_app: :my_plugin + plugin: Tribes.Plugins.Aether.Plugin, + otp_app: :aether end Until then, this file provides a standalone equivalent. @@ -17,7 +17,7 @@ defmodule MyPlugin.ContractTest do use ExUnit.Case, async: true - @plugin MyPlugin.Plugin + @plugin Tribes.Plugins.Aether.Plugin @manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand() setup_all do