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) ) cluster = Enum.find(items, &match?(%{type: "cluster", count: 3}, &1)) assert cluster assert_in_delta cluster.latitude, 50.45, 0.000_001 assert_in_delta cluster.longitude, 30.52, 0.000_001 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