who_need_help/test/who_need_help/notifications_test.exs

363 lines
13 KiB
Elixir

defmodule WhoNeedHelp.NotificationsTest do
use WhoNeedHelp.DataCase, async: false
use Oban.Testing, repo: WhoNeedHelp.Repo
import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions
alias WhoNeedHelp.{Catalog, Help, Notifications, Repo, Trust}
alias WhoNeedHelp.Notifications.{NearbySubscription, Notification, PushDevice}
alias WhoNeedHelp.ProductAnalytics.DailyMetric
alias WhoNeedHelp.Push.{NearbyMatchWorker, NotificationDispatchWorker, NotificationEmailWorker}
setup do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Nearby requester")
helper = user_fixture(display_name: "Nearby helper")
{:ok,
category: category,
requester: requester,
helper: helper,
requester_scope: user_scope_fixture(requester),
helper_scope: user_scope_fixture(helper)}
end
test "nearby subscriptions match by distance, category, urgency, and block state", context do
{:ok, subscription} =
Notifications.create_nearby_subscription(context.helper_scope, %{
"name" => "Central medicine",
"location_label" => "Private home area",
"latitude" => "50.4501",
"longitude" => "30.5234",
"radius_meters" => "3000",
"category_ids" => [context.category.id],
"urgencies" => ["now"],
"available_days" => Enum.map(1..7, &to_string/1),
"push_enabled" => "true",
"email_enabled" => "false"
})
{:ok, request} =
Help.create_request(context.requester_scope, request_attrs(context.category.id))
assert [matched] = Notifications.matching_nearby_subscriptions(request)
assert matched.id == subscription.id
{:ok, _block} = Trust.block(context.helper_scope, context.requester.id)
assert Notifications.matching_nearby_subscriptions(request) == []
end
test "far requests and non-matching urgency are not selected", context do
{:ok, _subscription} =
Notifications.create_nearby_subscription(context.helper_scope, %{
"name" => "Only scheduled nearby",
"location_label" => "Private center",
"latitude" => "50.4501",
"longitude" => "30.5234",
"radius_meters" => "1000",
"category_ids" => [context.category.id],
"urgencies" => ["scheduled"],
"available_days" => [1, 2, 3, 4, 5, 6, 7],
"push_enabled" => true,
"email_enabled" => false
})
{:ok, urgent_request} =
Help.create_request(context.requester_scope, request_attrs(context.category.id))
assert Notifications.matching_nearby_subscriptions(urgent_request) == []
far_attrs =
context.category.id
|> request_attrs()
|> Map.merge(%{
"urgency" => "scheduled",
"latitude" => "49.8397",
"longitude" => "24.0297"
})
{:ok, far_request} = Help.create_request(context.requester_scope, far_attrs)
assert Notifications.matching_nearby_subscriptions(far_request) == []
end
test "notification inbox is idempotent, user scoped, and records opens", context do
attrs = %{
kind: :request_accepted,
title: "A helper responded",
body: "Open the request to see the update.",
path: "/requests/#{Ecto.UUID.generate()}",
data: %{"request_id" => Ecto.UUID.generate()},
idempotency_key: "notification-test:#{Ecto.UUID.generate()}"
}
assert {:ok, first} = Notifications.notify_user(context.requester.id, attrs)
assert {:ok, replay} = Notifications.notify_user(context.requester.id, attrs)
assert replay.id == first.id
assert Notifications.unread_count(context.requester_scope) == 1
assert Notifications.unread_count(context.helper_scope) == 0
assert 1 ==
length(
all_enqueued(
worker: NotificationDispatchWorker,
args: %{"notification_id" => first.id}
)
)
assert {:error, :not_found} = Notifications.mark_read(context.helper_scope, first.id)
assert {:ok, opened} = Notifications.notification_opened(context.requester_scope, first.id)
assert opened.read_at
assert Notifications.unread_count(context.requester_scope) == 0
assert %DailyMetric{count: 1, dimension: "request_accepted"} =
Repo.get_by(DailyMetric,
date: Date.utc_today(),
metric: "notification.opened"
)
end
test "preferences enforce complete quiet hours and per-subscription push", context do
assert {:error, changeset} =
Notifications.update_preference(context.helper_scope, %{
"quiet_hours_enabled" => "true",
"quiet_start" => "22:00",
"quiet_end" => "",
"time_zone" => "Europe/Kyiv",
"utc_offset_minutes" => "180"
})
assert "can't be blank" in errors_on(changeset).quiet_end
assert {:ok, preference} =
Notifications.update_preference(context.helper_scope, %{
"push_enabled" => "true",
"nearby_push_enabled" => "true",
"quiet_hours_enabled" => "true",
"quiet_start" => "22:00",
"quiet_end" => "07:00",
"time_zone" => "Etc/UTC",
"utc_offset_minutes" => "0"
})
assert Notifications.quiet_now?(preference, ~U[2026-07-22 23:30:00Z])
refute Notifications.quiet_now?(preference, ~U[2026-07-22 12:00:00Z])
assert Notifications.next_quiet_end(preference, ~U[2026-07-22 23:30:00Z]) ==
~U[2026-07-23 07:00:00Z]
notification = %Notification{kind: :nearby_request, data: %{"push_enabled" => false}}
refute Notifications.push_allowed?(preference, notification)
no_channel_changeset =
Notifications.change_nearby_subscription(%NearbySubscription{}, %{
"name" => "No channel",
"location_label" => "Private area",
"latitude" => "50.4501",
"longitude" => "30.5234",
"radius_meters" => "3000",
"urgencies" => ["now"],
"available_days" => Enum.to_list(1..7),
"push_enabled" => "false",
"email_enabled" => "false"
})
assert "select push, email, or both" in errors_on(no_channel_changeset).push_enabled
end
test "nearby email is durable, generic, and enabled when any matching alert opts in", context do
assert_email_sent()
assert_email_sent()
assert {:ok, _preference} =
Notifications.update_preference(context.helper_scope, %{
"push_enabled" => "false",
"email_enabled" => "true",
"nearby_email_enabled" => "true",
"quiet_hours_enabled" => "false",
"time_zone" => "Etc/UTC",
"utc_offset_minutes" => "0"
})
common = %{
"latitude" => "50.4501",
"longitude" => "30.5234",
"radius_meters" => "3000",
"category_ids" => [context.category.id],
"urgencies" => ["now"],
"available_days" => Enum.to_list(1..7),
"push_enabled" => "false"
}
assert {:ok, _subscription} =
Notifications.create_nearby_subscription(
context.helper_scope,
Map.merge(common, %{
"name" => "Private home label",
"location_label" => "Do not send this label",
"push_enabled" => "true",
"email_enabled" => "false"
})
)
assert {:ok, _subscription} =
Notifications.create_nearby_subscription(
context.helper_scope,
Map.merge(common, %{
"name" => "Second private label",
"location_label" => "Another private place",
"email_enabled" => "true"
})
)
assert {:ok, request} =
Help.create_request(context.requester_scope, request_attrs(context.category.id))
assert :ok =
perform_job(NearbyMatchWorker, %{
"request_id" => request.id,
"event_key" => "created"
})
notification =
Repo.get_by!(Notification,
user_id: context.helper.id,
kind: :nearby_request
)
assert notification.data["email_enabled"]
assert notification.data["push_enabled"]
refute inspect(notification) =~ "Private home label"
refute inspect(notification) =~ "Do not send this label"
assert :ok =
perform_job(NotificationDispatchWorker, %{"notification_id" => notification.id})
assert_enqueued(
worker: NotificationEmailWorker,
queue: :push,
args: %{"notification_id" => notification.id}
)
assert :ok =
perform_job(NotificationEmailWorker, %{"notification_id" => notification.id})
assert_email_sent(fn email ->
assert email.to == [{"", context.helper.email}]
assert email.subject == "New help request nearby"
assert email.text_body =~ "/requests/#{request.id}"
refute email.text_body =~ "Private home label"
refute email.text_body =~ "Do not send this label"
true
end)
end
test "device registration is idempotent and follows an authenticated shared installation",
context do
attrs = %{
"platform" => "web",
"provider" => "web_push",
"token" => "https://push.example/subscriptions/#{Ecto.UUID.generate()}",
"installation_id" => Ecto.UUID.generate(),
"p256dh" => "test-public-key",
"auth_secret" => "test-auth-secret",
"device_label" => "Test browser",
"user_agent" => "Who Need Help test"
}
assert {:ok, first} = Notifications.register_device(context.helper_scope, attrs)
assert {:ok, refreshed} = Notifications.register_device(context.helper_scope, attrs)
assert refreshed.id == first.id
assert refreshed.last_seen_at
assert {:ok, transferred} =
Notifications.register_device(context.requester_scope, attrs)
assert transferred.id == first.id
assert transferred.user_id == context.requester.id
assert Notifications.list_devices(context.helper_scope) == []
assert [%PushDevice{id: device_id}] = Notifications.list_devices(context.requester_scope)
assert device_id == first.id
assert {:ok, _disabled} = Notifications.disable_device(context.requester_scope, first.id)
assert Notifications.list_devices(context.requester_scope) == []
end
test "a token cannot be claimed from a different installation", context do
token = "https://push.example/subscriptions/#{Ecto.UUID.generate()}"
attrs = %{
"platform" => "web",
"provider" => "web_push",
"token" => token,
"installation_id" => Ecto.UUID.generate(),
"p256dh" => "test-public-key",
"auth_secret" => "test-auth-secret",
"device_label" => "Test browser"
}
assert {:ok, _device} = Notifications.register_device(context.helper_scope, attrs)
assert {:error, :already_registered} =
Notifications.register_device(
context.requester_scope,
Map.put(attrs, "installation_id", Ecto.UUID.generate())
)
end
test "notification paths reject external URLs and allow an internal message anchor", context do
base = %{
kind: :message_created,
title: "New message",
body: "Open the app.",
idempotency_key: "path-test:#{Ecto.UUID.generate()}"
}
assert {:error, changeset} =
Notifications.notify_user(
context.helper.id,
Map.put(base, :path, "https://evil.example/requests")
)
assert errors_on(changeset).path == ["has invalid format"]
assert {:error, changeset} =
Notifications.notify_user(
context.helper.id,
base
|> Map.put(:path, "//evil.example/requests")
|> Map.put(:idempotency_key, "path-network:#{Ecto.UUID.generate()}")
)
assert errors_on(changeset).path == ["has invalid format"]
assert {:ok, notification} =
Notifications.notify_user(
context.helper.id,
base
|> Map.put(:path, "/requests/#{Ecto.UUID.generate()}#messages")
|> Map.put(:idempotency_key, "path-anchor:#{Ecto.UUID.generate()}")
)
assert String.ends_with?(notification.path, "#messages")
end
defp request_attrs(category_id) do
%{
"title" => "Medicine is ready nearby",
"description" => "Please collect the reserved legal medicine and bring it nearby.",
"pickup_instructions" => "Use the private chat for exact details.",
"location_label" => "Central district",
"latitude" => "50.4505",
"longitude" => "30.5240",
"location_radius_meters" => "1000",
"urgency" => "now",
"location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category_id,
"safety_confirmed" => true
}
end
end