312 lines
11 KiB
Elixir
312 lines
11 KiB
Elixir
defmodule WhoNeedHelp.ActivitiesTest do
|
|
use WhoNeedHelp.DataCase, async: false
|
|
|
|
import WhoNeedHelp.AccountsFixtures
|
|
|
|
alias WhoNeedHelp.{Activities, Catalog, Trust}
|
|
alias WhoNeedHelp.Activities.Activity
|
|
alias WhoNeedHelp.Repo
|
|
alias WhoNeedHelp.Trust.AuditEvent
|
|
|
|
setup do
|
|
Catalog.seed_defaults()
|
|
|
|
organizer = user_fixture(display_name: "Organizer")
|
|
participant = user_fixture(display_name: "Participant")
|
|
outsider = user_fixture(display_name: "Outsider")
|
|
|
|
coffee =
|
|
Catalog.list_categories(:activity)
|
|
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
|
|
|
attrs = %{
|
|
"title" => "Coffee and conversation",
|
|
"description" => "Meet at a public café for conversation and tea.",
|
|
"structured_data" => %{"setting" => "cafe"},
|
|
"location_label" => "Central square",
|
|
"latitude" => "50.4501",
|
|
"longitude" => "30.5234",
|
|
"location_visibility" => "approximate_public",
|
|
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
|
|
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
|
|
"capacity" => 2,
|
|
"category_id" => coffee.id
|
|
}
|
|
|
|
{:ok,
|
|
organizer: organizer,
|
|
participant: participant,
|
|
outsider: outsider,
|
|
organizer_scope: user_scope_fixture(organizer),
|
|
participant_scope: user_scope_fixture(participant),
|
|
outsider_scope: user_scope_fixture(outsider),
|
|
coffee: coffee,
|
|
attrs: attrs}
|
|
end
|
|
|
|
test "organizer approves a participant and the approved group can chat", context do
|
|
assert {:ok, activity} =
|
|
Activities.create_activity(context.organizer_scope, context.attrs)
|
|
|
|
assert [%{role: :organizer, status: :approved}] = activity.participants
|
|
|
|
assert Activity.public_coordinates(activity) == %{
|
|
latitude: 50.45,
|
|
longitude: 30.52,
|
|
exact: false
|
|
}
|
|
|
|
assert Activities.coordinates_for(context.outsider_scope, activity).exact == false
|
|
assert Activities.coordinates_for(context.organizer_scope, activity).exact == true
|
|
|
|
assert {:error, :forbidden} =
|
|
Activities.send_message(context.outsider_scope, activity.id, %{"body" => "Hello"})
|
|
|
|
assert {:ok, request} =
|
|
Activities.request_to_join(context.participant_scope, activity.id)
|
|
|
|
assert request.status == :requested
|
|
|
|
assert {:ok, approved} =
|
|
Activities.approve_participant(context.organizer_scope, request.id)
|
|
|
|
assert approved.status == :approved
|
|
|
|
assert {:ok, message} =
|
|
Activities.send_message(context.participant_scope, activity.id, %{
|
|
"body" => "See you at the café."
|
|
})
|
|
|
|
assert message.body == "See you at the café."
|
|
|
|
assert {:ok, loaded} = Activities.get_activity(context.participant_scope, activity.id)
|
|
assert [%{body: "See you at the café."}] = loaded.messages
|
|
assert Activities.coordinates_for(context.participant_scope, loaded).exact == true
|
|
|
|
assert {:error, :capacity_reached} =
|
|
Activities.request_to_join(context.outsider_scope, activity.id)
|
|
end
|
|
|
|
test "unapproved viewers receive no private chat or pending participant data", context do
|
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
|
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
|
|
|
|
assert {:ok, public_activity} =
|
|
Activities.get_activity(context.outsider_scope, activity.id)
|
|
|
|
assert public_activity.messages == []
|
|
assert Enum.all?(public_activity.participants, &(&1.status == :approved))
|
|
refute Enum.any?(public_activity.participants, &(&1.id == request.id))
|
|
|
|
assert {:ok, requester_view} =
|
|
Activities.get_activity(context.participant_scope, activity.id)
|
|
|
|
assert requester_view.messages == []
|
|
assert Enum.any?(requester_view.participants, &(&1.id == request.id))
|
|
end
|
|
|
|
test "activity completion never changes urgent-help reputation", context do
|
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
|
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
|
|
{:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)
|
|
assert {:ok, completed} = Activities.complete_activity(context.organizer_scope, activity.id)
|
|
assert completed.status == :completed
|
|
|
|
assert Trust.reputation(context.organizer.id).completed == 0
|
|
assert Trust.reputation(context.participant.id).completed == 0
|
|
assert {:error, :not_found} = Activities.get_activity(context.outsider_scope, activity.id)
|
|
assert {:ok, _activity} = Activities.get_activity(context.participant_scope, activity.id)
|
|
end
|
|
|
|
test "activity category and structured fields are enforced", context do
|
|
hiking =
|
|
Catalog.list_categories(:activity)
|
|
|> Enum.find(&(&1.slug == "hiking-group"))
|
|
|
|
invalid =
|
|
context.attrs
|
|
|> Map.put("category_id", hiking.id)
|
|
|> Map.put("structured_data", %{
|
|
"difficulty" => "easy",
|
|
"route_plan" => "Marked public trail"
|
|
})
|
|
|
|
assert {:error, changeset} =
|
|
Activities.create_activity(context.organizer_scope, invalid)
|
|
|
|
assert "safety_plan_confirmed is required" in errors_on(changeset).structured_data
|
|
|
|
help_category = Catalog.seed_defaults()
|
|
|
|
invalid =
|
|
context.attrs
|
|
|> Map.put("category_id", help_category.id)
|
|
|> Map.put("structured_data", %{"pickup_status" => "reserved"})
|
|
|
|
assert {:error, changeset} =
|
|
Activities.create_activity(context.organizer_scope, invalid)
|
|
|
|
assert "select an activity category" in errors_on(changeset).category_id
|
|
end
|
|
|
|
test "blocking prevents activity discovery and joining", context do
|
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
|
assert {:ok, _block} = Trust.block(context.organizer_scope, context.participant.id)
|
|
assert [] = Activities.list_open_activities(context.participant_scope)
|
|
|
|
assert {:error, :blocked} =
|
|
Activities.request_to_join(context.participant_scope, activity.id)
|
|
end
|
|
|
|
test "activity discovery uses stable cursor pages", context do
|
|
activities =
|
|
for offset <- 2..5 do
|
|
attrs =
|
|
context.attrs
|
|
|> Map.put("title", "Cursor activity #{offset}")
|
|
|> Map.put(
|
|
"starts_at",
|
|
DateTime.utc_now(:second) |> DateTime.add(offset, :hour)
|
|
)
|
|
|> Map.put(
|
|
"join_deadline",
|
|
DateTime.utc_now(:second) |> DateTime.add(offset - 1, :hour)
|
|
)
|
|
|
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
|
|
activity
|
|
end
|
|
|
|
first = Activities.paginate_open_activities(context.outsider_scope, %{}, limit: 2)
|
|
|
|
second =
|
|
Activities.paginate_open_activities(context.outsider_scope, %{},
|
|
limit: 2,
|
|
after: first.next_cursor
|
|
)
|
|
|
|
expected_ids =
|
|
activities
|
|
|> Enum.sort_by(&{&1.starts_at, &1.id})
|
|
|> Enum.map(& &1.id)
|
|
|
|
actual_ids = Enum.map(first.entries ++ second.entries, & &1.id)
|
|
|
|
assert actual_ids == expected_ids
|
|
assert length(Enum.uniq(actual_ids)) == 4
|
|
assert is_binary(first.next_cursor)
|
|
assert second.next_cursor == nil
|
|
assert Enum.all?(first.entries ++ second.entries, &(&1.approved_participant_count == 1))
|
|
|
|
assert Enum.all?(first.entries ++ second.entries, fn activity ->
|
|
match?(%Ecto.Association.NotLoaded{}, activity.participants)
|
|
end)
|
|
end
|
|
|
|
test "my activities are derived from the organizer and participant membership rows", context do
|
|
{:ok, organized} = Activities.create_activity(context.organizer_scope, context.attrs)
|
|
{:ok, request} = Activities.request_to_join(context.participant_scope, organized.id)
|
|
|
|
assert Enum.map(Activities.list_my_activities(context.organizer_scope), & &1.id) == [
|
|
organized.id
|
|
]
|
|
|
|
assert Enum.map(Activities.list_my_activities(context.participant_scope), & &1.id) == [
|
|
organized.id
|
|
]
|
|
|
|
assert {:ok, _left} = Activities.leave_activity(context.participant_scope, organized.id)
|
|
assert Activities.list_my_activities(context.participant_scope) == []
|
|
assert request.status == :requested
|
|
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 == []
|
|
|
|
expected_message_ids =
|
|
[organizer_message, participant_message]
|
|
|> Enum.sort_by(&{&1.inserted_at, &1.id})
|
|
|> Enum.map(& &1.id)
|
|
|
|
assert Enum.map(evidence.activity_messages, & &1.id) == expected_message_ids
|
|
|
|
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
|