who_need_help/test/who_need_help/activities_test.exs

663 lines
24 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.Help.DiscoveryViewport
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,
"safety_confirmed" => true
}
{: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 "an incomplete start time does not get a misleading future error", context do
changeset =
Activity.create_changeset(%Activity{}, Map.put(context.attrs, "starts_at", ""))
assert "can't be blank" in errors_on(changeset).starts_at
refute "must be in the future" in errors_on(changeset).starts_at
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 "global activity events carry only the bounded index projection", context do
:ok = Activities.subscribe()
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
assert_receive {:activity_created, created}
assert created.id == activity.id
assert created.approved_participant_count == 1
assert %Ecto.Association.NotLoaded{} = created.participants
assert %Ecto.Association.NotLoaded{} = created.messages
assert %Ecto.Association.NotLoaded{} = created.creator.social_identities
:ok = Activities.subscribe_user(context.participant_scope)
{:ok, _request} = Activities.request_to_join(context.participant_scope, activity.id)
assert_receive {:activity_updated, updated}
assert updated.id == activity.id
assert updated.approved_participant_count == 1
assert %Ecto.Association.NotLoaded{} = updated.participants
assert %Ecto.Association.NotLoaded{} = updated.messages
assert_receive {:my_activity_updated, participant_update, true}
assert participant_update.id == activity.id
end
test "invalid and missing client identifiers are rejected without raising", context do
assert {:error, :not_found} =
Activities.approve_participant(context.organizer_scope, "not-a-uuid")
assert {:error, :not_found} =
Activities.decline_participant(context.organizer_scope, "not-a-uuid")
missing_id = Ecto.UUID.generate()
assert {:error, :not_found} =
Activities.approve_participant(context.organizer_scope, missing_id)
assert {:error, :not_found} =
Activities.decline_participant(context.organizer_scope, missing_id)
assert {:error, :not_found} =
Activities.request_to_join(context.participant_scope, missing_id)
assert {:error, :not_found} =
Activities.send_message(context.participant_scope, missing_id, %{"body" => "No"})
assert {:error, :not_found} =
Activities.cancel_activity(context.organizer_scope, missing_id)
assert {:error, :not_found} =
Activities.complete_activity(context.organizer_scope, missing_id)
for invalid_id <- ["not-a-uuid", missing_id] do
assert {:error, :not_found} =
Activities.leave_activity(context.participant_scope, invalid_id)
assert {:error, :not_found} =
Activities.request_to_join(context.participant_scope, invalid_id)
assert {:error, :not_found} =
Activities.send_message(context.participant_scope, invalid_id, %{"body" => "No"})
assert {:error, :not_found} =
Activities.cancel_activity(context.organizer_scope, invalid_id)
assert {:error, :not_found} =
Activities.complete_activity(context.organizer_scope, invalid_id)
end
end
test "activity location labels are validated before the database write", context do
assert {:error, changeset} =
Activities.create_activity(
context.organizer_scope,
Map.put(context.attrs, "location_label", String.duplicate("x", 256))
)
assert "should be at most 255 character(s)" in errors_on(changeset).location_label
end
test "activity capacity outside the PostgreSQL integer range is rejected before insert",
context do
assert {:error, changeset} =
Activities.create_activity(
context.organizer_scope,
Map.put(context.attrs, "capacity", 2_147_483_648)
)
assert "must be less than or equal to 2147483647" in errors_on(changeset).capacity
end
test "forged coordinate types become validation errors instead of raising", context do
assert {:error, changeset} =
Activities.create_activity(
context.organizer_scope,
Map.put(context.attrs, "latitude", %{"nested" => "value"})
)
assert "select a valid location" in errors_on(changeset).location
end
test "forged category filters return no activities instead of raising", context do
assert {:ok, _activity} =
Activities.create_activity(context.organizer_scope, context.attrs)
assert [] ==
Activities.list_open_activities(context.participant_scope, %{
"category_id" => "not-a-uuid"
})
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 "blocking an approved participant revokes exact location and group chat", 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)
{:ok, loaded} = Activities.get_activity(context.participant_scope, activity.id)
assert Activities.coordinates_for(context.participant_scope, loaded).exact
assert {:ok, _block} = Trust.block(context.organizer_scope, context.participant.id)
assert {:error, :not_found} =
Activities.get_activity(context.participant_scope, activity.id)
assert Activities.coordinates_for(context.participant_scope, loaded).exact == false
assert Activities.paginate_messages(context.participant_scope, loaded).entries == []
assert {:error, :blocked} =
Activities.send_message(context.participant_scope, activity.id, %{
"body" => "This must not be delivered"
})
end
test "activity creation requires server-side safety consent", context do
attrs = Map.delete(context.attrs, "safety_confirmed")
assert {:error, changeset} =
Activities.create_activity(context.organizer_scope, attrs)
assert "confirm the safety guidance before publishing" in errors_on(changeset).safety_confirmed
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 "activity discovery loads only the selected map viewport", context do
{:ok, nearby} =
Activities.create_activity(
context.organizer_scope,
context.attrs
|> Map.put("title", "Nearby activity")
|> Map.put("latitude", "50.4501")
|> Map.put("longitude", "30.5234")
)
{:ok, far_away} =
Activities.create_activity(
context.organizer_scope,
context.attrs
|> Map.put("title", "Far-away activity")
|> Map.put("latitude", "52.5200")
|> Map.put("longitude", "13.4050")
)
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
ids =
context.outsider_scope
|> Activities.paginate_open_activities(%{}, viewport: viewport, limit: 100)
|> Map.fetch!(:entries)
|> Enum.map(& &1.id)
assert nearby.id in ids
refute far_away.id in ids
end
test "activity discovery filters by the published approximate point, not the exact point",
context do
{:ok, activity} =
Activities.create_activity(
context.organizer_scope,
context.attrs
|> Map.put("title", "Privacy-safe viewport activity")
|> Map.put("latitude", "50.4501")
|> Map.put("longitude", "30.5234")
)
exact_only_viewport = viewport!(30.523, 50.449, 30.524, 50.451, 16)
exact_only_entries =
context.outsider_scope
|> Activities.paginate_open_activities(%{},
viewport: exact_only_viewport,
limit: 100
)
|> Map.fetch!(:entries)
refute Enum.any?(exact_only_entries, &(&1.id == activity.id))
published_point_viewport = viewport!(30.519, 50.449, 30.521, 50.451, 16)
published_entries =
context.outsider_scope
|> Activities.paginate_open_activities(%{},
viewport: published_point_viewport,
limit: 100
)
|> Map.fetch!(:entries)
assert Enum.any?(published_entries, &(&1.id == activity.id))
end
test "activity map discovery clusters dense points and keeps sparse points", context do
for {offset, index} <- Enum.with_index([0.0, 0.00005, 0.0001], 1) do
assert {:ok, _activity} =
Activities.create_activity(
context.organizer_scope,
context.attrs
|> Map.put("title", "Dense activity #{index}")
|> Map.put("longitude", Float.to_string(30.5234 + offset))
)
end
{:ok, sparse} =
Activities.create_activity(
context.organizer_scope,
context.attrs
|> Map.put("title", "Sparse activity")
|> Map.put("latitude", "50.8")
|> Map.put("longitude", "30.8")
)
items =
Activities.map_discovery_items(
context.outsider_scope,
%{},
viewport!(30.0, 50.0, 31.0, 51.0, 9)
)
assert Enum.any?(items, &match?(%{type: "cluster", count: 3}, &1))
assert Enum.any?(items, fn
%{type: "activity", id: id} -> id == sparse.id
_item -> false
end)
end
test "hidden activity is discoverable only through its public area description", context do
{:ok, hidden} =
Activities.create_activity(
context.organizer_scope,
context.attrs
|> Map.put("title", "Hidden-map activity")
|> Map.put("location_label", "Kyiv Podil riverside")
|> Map.put("location_visibility", "hidden")
)
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
without_area =
Activities.paginate_open_activities(context.outsider_scope, %{},
viewport: viewport,
limit: 100
)
refute Enum.any?(without_area.entries, &(&1.id == hidden.id))
with_area =
Activities.paginate_open_activities(
context.outsider_scope,
%{"area" => "Podil"},
viewport: viewport,
limit: 100
)
assert Enum.any?(with_area.entries, &(&1.id == hidden.id))
assert Activities.map_discovery_items(context.outsider_scope, %{"area" => "Podil"}, viewport) ==
[]
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 "leaving revokes private activity access and rejoining returns to approval pending",
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)
{:ok, approved_activity} = Activities.get_activity(context.participant_scope, activity.id)
assert Activities.coordinates_for(context.participant_scope, approved_activity).exact
assert {:ok, _message} =
Activities.send_message(context.participant_scope, activity.id, %{
"body" => "Visible before leaving"
})
assert {:ok, left} = Activities.leave_activity(context.participant_scope, activity.id)
assert left.status == :left
assert {:ok, left_activity} = Activities.get_activity(context.participant_scope, activity.id)
assert Activities.coordinates_for(context.participant_scope, left_activity).exact == false
assert Activities.paginate_messages(context.participant_scope, left_activity).entries == []
assert {:error, :forbidden} =
Activities.send_message(context.participant_scope, activity.id, %{
"body" => "Must not be visible after leaving"
})
assert {:ok, rejoined} =
Activities.request_to_join(context.participant_scope, activity.id)
assert rejoined.id == request.id
assert rejoined.status == :requested
assert is_nil(rejoined.reviewed_at)
assert is_nil(rejoined.left_at)
assert Enum.map(Activities.list_my_activities(context.participant_scope), & &1.id) == [
activity.id
]
end
test "my activities exclude organizers blocked in either direction", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
{:ok, _request} = Activities.request_to_join(context.participant_scope, activity.id)
assert Enum.map(Activities.list_my_activities(context.participant_scope), & &1.id) == [
activity.id
]
assert {:ok, _block} = Trust.block(context.participant_scope, context.organizer.id)
assert Activities.list_my_activities(context.participant_scope) == []
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
defp viewport!(west, south, east, north, zoom) do
{:ok, viewport} =
DiscoveryViewport.cast(%{
west: west,
south: south,
east: east,
north: north,
zoom: zoom,
width: 1_200,
height: 720
})
viewport
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