From 2a1a2d93eb885d1c15f2a2f24669c670d73fe51e Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 18 Jul 2026 20:48:31 +0300 Subject: [PATCH] feat: add scoped activity reports --- README.md | 3 +- docs/trust-safety.md | 5 + docs/verification.md | 7 +- lib/who_need_help/activities.ex | 7 + lib/who_need_help/trust.ex | 145 +++++++++++++++++- lib/who_need_help/trust/report.ex | 14 +- .../live/activity_live/show.ex | 119 +++++++++++++- lib/who_need_help_web/live/moderation_live.ex | 41 ++++- ...18174233_extend_reports_for_activities.exs | 37 +++++ test/who_need_help/activities_test.exs | 89 +++++++++++ .../live/mutual_aid_live_test.exs | 3 + 11 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 priv/repo/migrations/20260718174233_extend_reports_for_activities.exs diff --git a/README.md b/README.md index 3bd5c86..f240b0a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ thank-you link; money goes directly between users outside the platform. - Separate social Activity mode for coffee, cinema, walks, and hikes, with organizer-approved membership, capacity-safe joins, private group chat, and exact coordinates visible only to approved participants. Activities never - affect urgent-helper reputation. + affect urgent-helper reputation; Activity and Activity-message reports expose + only the linked evidence to audited moderators. - Request lifecycle: `open → matched → in_progress → completed`, plus cancel and expiry paths. - PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix diff --git a/docs/trust-safety.md b/docs/trust-safety.md index 4f8a7d8..d4a0be7 100644 --- a/docs/trust-safety.md +++ b/docs/trust-safety.md @@ -99,6 +99,11 @@ report. Every access records the moderator, report, linked assignment, message count, and timestamp. The report itself contains the reason. Browsing private conversations without a qualifying report is not a product capability. +The same boundary applies to Activity group chat. Reporting an Activity itself +does not reveal its private chat. Only a report targeting a specific Activity +message permits the moderator to load that Activity's group conversation, and +the evidence access audit records the Activity and message count. + Report, request, signal, category, role, and account moderation are role protected. The local Codex batch receives proposal text and aggregate vote counts only. It receives no diff --git a/docs/verification.md b/docs/verification.md index 64cab86..1a3d0ed 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -10,7 +10,7 @@ results from product limits and unknown production properties. | Urgent medicine-help flow | Implemented and tested | Request creation, discovery, matching, start, handover, two-party completion, and review rules are covered by the Phoenix test suite and exercised in the local UI. | The product coordinates pickup of an already purchased or reserved legal item; it is not a pharmacy, medical, or emergency service. | | Urgent roadside help | Implemented and tested | Fuel, car wheel, bicycle, motorcycle, vehicle-breakdown, and secured-incident categories are seeded as a translated hierarchy. Server and LiveView tests exercise category paths, required fields, allowed values, boolean normalization, unknown-field rejection, and request creation. | Roadside requests require no immediate danger; this is not emergency response or professional recovery. | | Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities remain separate from urgent-help safety and ranking rules. | -| Separate Activity mode | Implemented and tested | Coffee, cinema, walk, and hiking categories use a separate activity lifecycle. Domain and two-client LiveView tests cover creation, join request, organizer approval, capacity enforcement, public/pending/chat privacy, exact-location disclosure to approved users, group chat, blocking, completion, and zero impact on helper reputation. | Activity reporting and moderator evidence are handled as part of the broader trust/moderation rollout; this does not guarantee participant identity or physical safety. | +| Separate Activity mode | Implemented and tested | Coffee, cinema, walk, and hiking categories use a separate activity lifecycle. Domain and two-client LiveView tests cover creation, join request, organizer approval, capacity enforcement, public/pending/chat privacy, exact-location disclosure to approved users, group chat, blocking, completion, and zero impact on helper reputation. Activity and message reports expose only the linked group conversation to an audited moderator; moderators can hide and restore reported activities. | This does not guarantee participant identity or physical safety. | | Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. | | Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. | | Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. | @@ -23,10 +23,13 @@ results from product limits and unknown production properties. ## Reproducible checks -- `./scripts/test.sh`: 134 tests, 0 failures after the Activity rollout +- `./scripts/test.sh`: 136 tests, 0 failures after scoped Activity reporting on Elixir 1.20.2 and Erlang/OTP 29.0.3. - The generated Activity migration was rolled back by exactly one step and migrated forward again against `who_need_help_test`; both directions passed. +- The Activity-report migration was also rolled back and migrated forward. The + observed database constraint changed from exactly one of 3 urgent-help + targets to exactly one of 5 urgent-help/Activity targets. - `mix format --check-formatted`: passed in the final run. - Android Docker build target: `testDebugUnitTest`, `lintDebug`, and `assembleDebug` passed; the final lint report contains no errors or warnings. diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex index 1dc3518..ab2c021 100644 --- a/lib/who_need_help/activities.ex +++ b/lib/who_need_help/activities.ex @@ -20,6 +20,13 @@ defmodule WhoNeedHelp.Activities do def subscribe_activity(id), do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activity:#{id}") + def notify_activity_updated(id) do + activity = load_activity(id) + broadcast({:activity_updated, activity}) + broadcast_activity(id, {:activity_updated, activity}) + :ok + end + def list_open_activities(%Scope{user: user}, filters \\ %{}) do now = DateTime.utc_now(:second) diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex index 65172f6..777f4f5 100644 --- a/lib/who_need_help/trust.ex +++ b/lib/who_need_help/trust.ex @@ -5,6 +5,9 @@ defmodule WhoNeedHelp.Trust do alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.{Scope, User} + alias WhoNeedHelp.Activities + alias WhoNeedHelp.Activities.{Activity, Participant} + alias WhoNeedHelp.Activities.Message, as: ActivityMessage alias WhoNeedHelp.Help alias WhoNeedHelp.Help.{Assignment, HelpRequest} alias WhoNeedHelp.Messaging.Message @@ -203,7 +206,9 @@ defmodule WhoNeedHelp.Trust do :reviewed_by, request: :requester, assignment: :helper, - message: :sender + message: :sender, + activity: :creator, + activity_message: :sender ]) |> Repo.all() else @@ -243,7 +248,14 @@ defmodule WhoNeedHelp.Trust do report = Report |> Repo.get(report_id) - |> Repo.preload([:reporter, :request, :assignment, message: :assignment]) + |> Repo.preload([ + :reporter, + :request, + :assignment, + :activity, + message: :assignment, + activity_message: :activity + ]) if report do assignment_id = @@ -264,12 +276,31 @@ defmodule WhoNeedHelp.Trust do [] end + activity_id = + case report.activity_message do + %ActivityMessage{activity_id: activity_id} -> activity_id + _ -> nil + end + + activity_messages = + if activity_id do + ActivityMessage + |> where([message], message.activity_id == ^activity_id) + |> order_by([message], asc: message.inserted_at) + |> preload(:sender) + |> Repo.all() + else + [] + end + with {:ok, _audit} <- audit(moderator.id, "report.evidence_viewed", "report", report.id, %{ "assignment_id" => assignment_id, - "message_count" => length(messages) + "activity_id" => activity_id, + "message_count" => length(messages), + "activity_message_count" => length(activity_messages) }) do - {:ok, %{report: report, messages: messages}} + {:ok, %{report: report, messages: messages, activity_messages: activity_messages}} end else {:error, :not_found} @@ -498,6 +529,68 @@ defmodule WhoNeedHelp.Trust do end end + def hide_activity(%Scope{user: moderator}, activity_id, reason) do + result = + if Accounts.moderator_authorized?(moderator) do + Repo.transact(fn -> + activity = + Activity + |> where([activity], activity.id == ^activity_id) + |> lock("FOR UPDATE") + |> Repo.one!() + + with {:ok, activity} <- + activity + |> Activity.moderation_changeset(%{ + hidden_at: DateTime.utc_now(:second), + hidden_reason: reason + }) + |> Repo.update() do + audit(moderator.id, "activity.hidden", "activity", activity.id, %{ + "reason" => reason + }) + + {:ok, activity} + end + end) + else + {:error, :forbidden} + end + + with {:ok, activity} <- result do + Activities.notify_activity_updated(activity.id) + {:ok, activity} + end + end + + def restore_activity(%Scope{user: moderator}, activity_id) do + result = + if Accounts.moderator_authorized?(moderator) do + Repo.transact(fn -> + activity = + Activity + |> where([activity], activity.id == ^activity_id) + |> lock("FOR UPDATE") + |> Repo.one!() + + with {:ok, activity} <- + activity + |> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil}) + |> Repo.update() do + audit(moderator.id, "activity.restored", "activity", activity.id) + {:ok, activity} + end + end) + else + {:error, :forbidden} + end + + with {:ok, activity} <- result do + Activities.notify_activity_updated(activity.id) + {:ok, activity} + end + end + def moderate_user(%Scope{user: moderator}, user_id, attrs) do Repo.transact(fn -> with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs), @@ -606,8 +699,52 @@ defmodule WhoNeedHelp.Trust do end end + defp authorize_report_target(%Scope{user: user}, %{"activity_id" => activity_id}) + when is_binary(activity_id) do + case Repo.get(Activity, activity_id) do + %Activity{creator_id: creator_id} when creator_id == user.id -> + {:error, :cannot_report_self} + + %Activity{} = activity -> + if is_nil(activity.hidden_at) or activity_participant?(activity.id, user.id), + do: :ok, + else: {:error, :not_found} + + nil -> + {:error, :not_found} + end + end + + defp authorize_report_target( + %Scope{user: user}, + %{"activity_message_id" => message_id} + ) + when is_binary(message_id) do + case Repo.get(ActivityMessage, message_id) do + %ActivityMessage{sender_id: sender_id} when sender_id == user.id -> + {:error, :cannot_report_self} + + %ActivityMessage{activity_id: activity_id} -> + if activity_participant?(activity_id, user.id), + do: :ok, + else: {:error, :forbidden} + + nil -> + {:error, :not_found} + end + end + defp authorize_report_target(_scope, _attrs), do: {:error, :invalid_target} + defp activity_participant?(activity_id, user_id) do + Repo.exists?( + from participant in Participant, + where: + participant.activity_id == ^activity_id and participant.user_id == ^user_id and + participant.status == :approved + ) + end + defp create_signal_once(kind, subject_id, assignment_id, metadata) do query = from signal in AbuseSignal, diff --git a/lib/who_need_help/trust/report.ex b/lib/who_need_help/trust/report.ex index 1cbd13c..9678bac 100644 --- a/lib/who_need_help/trust/report.ex +++ b/lib/who_need_help/trust/report.ex @@ -26,12 +26,22 @@ defmodule WhoNeedHelp.Trust.Report do belongs_to :request, WhoNeedHelp.Help.HelpRequest belongs_to :assignment, WhoNeedHelp.Help.Assignment belongs_to :message, WhoNeedHelp.Messaging.Message + belongs_to :activity, WhoNeedHelp.Activities.Activity + belongs_to :activity_message, WhoNeedHelp.Activities.Message timestamps(type: :utc_datetime) end def changeset(report, attrs) do report - |> cast(attrs, [:reason, :details, :request_id, :assignment_id, :message_id]) + |> cast(attrs, [ + :reason, + :details, + :request_id, + :assignment_id, + :message_id, + :activity_id, + :activity_message_id + ]) |> validate_required([:reason, :details, :reporter_id]) |> validate_length(:details, min: 5, max: 2_000) |> validate_single_target() @@ -47,7 +57,7 @@ defmodule WhoNeedHelp.Trust.Report do defp validate_single_target(changeset) do targets = - [:request_id, :assignment_id, :message_id] + [:request_id, :assignment_id, :message_id, :activity_id, :activity_message_id] |> Enum.count(&(not is_nil(get_field(changeset, &1)))) if targets == 1, diff --git a/lib/who_need_help_web/live/activity_live/show.ex b/lib/who_need_help_web/live/activity_live/show.ex index 5344477..6318b08 100644 --- a/lib/who_need_help_web/live/activity_live/show.ex +++ b/lib/who_need_help_web/live/activity_live/show.ex @@ -1,7 +1,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do use WhoNeedHelpWeb, :live_view - alias WhoNeedHelp.Activities + alias WhoNeedHelp.{Activities, Trust} @impl true def mount(%{"id" => id}, _session, socket) do @@ -82,6 +82,57 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do end end + def handle_event("report", %{"report" => params}, socket) do + target = + if socket.assigns.report_message_id do + %{"activity_message_id" => socket.assigns.report_message_id} + else + %{"activity_id" => socket.assigns.activity.id} + end + + case Trust.report(socket.assigns.current_scope, Map.merge(params, target)) do + {:ok, _report} -> + {:noreply, + socket + |> assign(:report_form, report_form()) + |> assign(:report_message_id, nil) + |> put_flash(:info, "Report sent to moderators.")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, error_message(reason))} + end + end + + def handle_event("select-report-message", %{"id" => message_id}, socket) do + message = + Enum.find(socket.assigns.activity.messages, fn message -> + message.id == message_id and message.sender_id != socket.assigns.current_scope.user.id + end) + + if message do + {:noreply, assign(socket, :report_message_id, message.id)} + else + {:noreply, put_flash(socket, :error, "That message cannot be reported from this view.")} + end + end + + def handle_event("clear-report-message", _params, socket) do + {:noreply, assign(socket, :report_message_id, nil)} + end + + def handle_event("block-organizer", _params, socket) do + case Trust.block(socket.assigns.current_scope, socket.assigns.activity.creator_id) do + {:ok, _block} -> + {:noreply, + socket + |> put_flash(:info, "Organizer blocked. Their activities and messages are hidden.") + |> push_navigate(to: ~p"/activities")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, error_message(reason))} + end + end + defp respond(socket, {:ok, _value}), do: {:noreply, reload(socket)} defp respond(socket, {:error, reason}) do @@ -131,6 +182,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do |> assign(:coordinates, coordinates) |> assign(:markers, Jason.encode!(markers)) |> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message)) + |> assign(:report_message_id, nil) + |> assign(:report_form, report_form()) end defp can_request_join?(activity, nil), do: activity.status == :open @@ -149,10 +202,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do defp ordered_messages(activity), do: Enum.sort_by(activity.messages, & &1.inserted_at, DateTime) + defp report_form do + to_form(%{"reason" => "harassment", "details" => ""}, as: :report) + end + defp error_message(:already_joined), do: "You already have a join request for this activity." defp error_message(:capacity_reached), do: "The approved group has reached its capacity." defp error_message(:join_closed), do: "Join requests are closed." defp error_message(:blocked), do: "This action is unavailable." + defp error_message(:cannot_report_self), do: "You cannot report your own content." defp error_message(:forbidden), do: "You are not allowed to do that." defp error_message(:invalid_transition), do: "This activity state has already changed." defp error_message(:not_open), do: "This activity is no longer open." @@ -236,8 +294,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do :for={message <- ordered_messages(@activity)} class="rounded-2xl bg-base-100 p-4" > -
- {message.sender.display_name || "Participant"} +
+
+ {message.sender.display_name || "Participant"} +
+

{message.body}

@@ -351,6 +420,50 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do + +
+

Safety controls

+ + <.form for={@report_form} phx-submit="report" class="mt-4 space-y-2"> +
+ The report is scoped to the selected group message. + +
+ <.input + field={@report_form[:reason]} + type="select" + label="Reason" + options={[ + {"Harassment", "harassment"}, + {"Dangerous plan", "dangerous_request"}, + {"Fraud", "fraud"}, + {"Spam", "spam"}, + {"Impersonation", "impersonation"}, + {"Other", "other"} + ]} + /> + <.input field={@report_form[:details]} type="textarea" label="What happened?" /> + <.button class="btn btn-error btn-outline w-full">Send report + +
diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex index 7fd3121..3cd3bfa 100644 --- a/lib/who_need_help_web/live/moderation_live.ex +++ b/lib/who_need_help_web/live/moderation_live.ex @@ -66,6 +66,22 @@ defmodule WhoNeedHelpWeb.ModerationLive do ) end + def handle_event("hide-activity", %{"id" => id, "moderation" => %{"note" => note}}, socket) do + respond( + socket, + Trust.hide_activity(socket.assigns.current_scope, id, note), + "Activity hidden." + ) + end + + def handle_event("restore-activity", %{"id" => id}, socket) do + respond( + socket, + Trust.restore_activity(socket.assigns.current_scope, id), + "Activity restored." + ) + end + def handle_event("approve-proposal", %{"id" => id, "category" => params}, socket) do names = %{ @@ -168,6 +184,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{report.reason} {report.status} + activity + + activity message + by {report.reporter.display_name}

{report.details}

@@ -189,6 +209,16 @@ defmodule WhoNeedHelpWeb.ModerationLive do <.input field={hide_form[:note]} placeholder="Hide reason" /> <.button class="btn btn-sm btn-error self-end">Hide request + <.form + :if={report.activity_id} + for={hide_form} + phx-submit="hide-activity" + phx-value-id={report.activity_id} + class="flex gap-2" + > + <.input field={hide_form[:note]} placeholder="Hide reason" /> + <.button class="btn btn-sm btn-error self-end">Hide activity + <.form for={report_form} @@ -221,7 +251,16 @@ defmodule WhoNeedHelpWeb.ModerationLive do
{message.sender.display_name}: {message.body}
-

+

+ {message.sender.display_name}: {message.body} +
+

This report has no linked conversation.

diff --git a/priv/repo/migrations/20260718174233_extend_reports_for_activities.exs b/priv/repo/migrations/20260718174233_extend_reports_for_activities.exs new file mode 100644 index 0000000..81c523b --- /dev/null +++ b/priv/repo/migrations/20260718174233_extend_reports_for_activities.exs @@ -0,0 +1,37 @@ +defmodule WhoNeedHelp.Repo.Migrations.ExtendReportsForActivities do + use Ecto.Migration + + def up do + execute "ALTER TABLE reports DROP CONSTRAINT IF EXISTS report_exactly_one_target" + + alter table(:reports) do + add :activity_id, references(:activities, type: :binary_id, on_delete: :nilify_all) + + add :activity_message_id, + references(:activity_messages, type: :binary_id, on_delete: :nilify_all) + end + + create index(:reports, [:activity_id]) + create index(:reports, [:activity_message_id]) + + create constraint(:reports, :report_exactly_one_target, + check: + "num_nonnulls(request_id, assignment_id, message_id, activity_id, activity_message_id) = 1" + ) + end + + def down do + execute "ALTER TABLE reports DROP CONSTRAINT IF EXISTS report_exactly_one_target" + drop index(:reports, [:activity_message_id]) + drop index(:reports, [:activity_id]) + + alter table(:reports) do + remove :activity_message_id + remove :activity_id + end + + create constraint(:reports, :report_exactly_one_target, + check: "num_nonnulls(request_id, assignment_id, message_id) = 1" + ) + end +end diff --git a/test/who_need_help/activities_test.exs b/test/who_need_help/activities_test.exs index 067c84e..3b9c5d2 100644 --- a/test/who_need_help/activities_test.exs +++ b/test/who_need_help/activities_test.exs @@ -5,6 +5,8 @@ defmodule WhoNeedHelp.ActivitiesTest do alias WhoNeedHelp.{Activities, Catalog, Trust} alias WhoNeedHelp.Activities.Activity + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Trust.AuditEvent setup do Catalog.seed_defaults() @@ -155,4 +157,91 @@ defmodule WhoNeedHelp.ActivitiesTest do assert {:error, :blocked} = Activities.request_to_join(context.participant_scope, activity.id) end + + test "activity message reports expose only the linked group to audited moderators", context do + moderator = + user_fixture(display_name: "Activity moderator") + |> Ecto.Changeset.change(role: :moderator) + |> Repo.update!() + + attrs = Map.put(context.attrs, "capacity", 3) + {:ok, activity} = Activities.create_activity(context.organizer_scope, attrs) + {:ok, request} = Activities.request_to_join(context.participant_scope, activity.id) + {:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id) + + {:ok, organizer_message} = + Activities.send_message(context.organizer_scope, activity.id, %{ + "body" => "Meet beside the public entrance." + }) + + {:ok, _participant_message} = + Activities.send_message(context.participant_scope, activity.id, %{ + "body" => "Understood." + }) + + assert {:error, :forbidden} = + Trust.report(context.outsider_scope, %{ + "reason" => "harassment", + "details" => "I should not see this private message.", + "activity_message_id" => organizer_message.id + }) + + assert {:ok, report} = + Trust.report(context.participant_scope, %{ + "reason" => "harassment", + "details" => "Please review this approved group conversation.", + "activity_message_id" => organizer_message.id + }) + + assert {:ok, evidence} = + Trust.report_evidence(user_scope_fixture(moderator), report.id) + + assert evidence.messages == [] + + assert Enum.map(evidence.activity_messages, & &1.body) == [ + "Meet beside the public entrance.", + "Understood." + ] + + assert Repo.exists?( + from event in AuditEvent, + where: + event.action == "report.evidence_viewed" and event.target_id == ^report.id and + event.actor_id == ^moderator.id + ) + end + + test "moderators can hide and restore a reported activity", context do + moderator = + user_fixture(display_name: "Activity moderator") + |> Ecto.Changeset.change(role: :moderator) + |> Repo.update!() + + moderator_scope = user_scope_fixture(moderator) + {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs) + + assert {:error, :cannot_report_self} = + Trust.report(context.organizer_scope, %{ + "reason" => "other", + "details" => "Cannot report my own activity.", + "activity_id" => activity.id + }) + + assert {:ok, _report} = + Trust.report(context.outsider_scope, %{ + "reason" => "dangerous_request", + "details" => "The public plan needs moderator review.", + "activity_id" => activity.id + }) + + assert {:ok, hidden} = + Trust.hide_activity(moderator_scope, activity.id, "Safety review") + + assert hidden.hidden_at + assert {:error, :not_found} = Activities.get_activity(context.outsider_scope, activity.id) + + assert {:ok, restored} = Trust.restore_activity(moderator_scope, activity.id) + assert is_nil(restored.hidden_at) + assert {:ok, _activity} = Activities.get_activity(context.outsider_scope, activity.id) + end end diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs index 987fb10..bd745bb 100644 --- a/test/who_need_help_web/live/mutual_aid_live_test.exs +++ b/test/who_need_help_web/live/mutual_aid_live_test.exs @@ -120,6 +120,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do {:ok, organizer_view, _html} = live(organizer_conn, ~p"/activities/#{activity.id}") {:ok, participant_view, html} = live(participant_conn, ~p"/activities/#{activity.id}") assert html =~ "Request to join" + assert html =~ "Safety controls" + assert html =~ "Block organizer" + assert html =~ "Send report" refute html =~ "Approved group chat" participant_view