363 lines
13 KiB
Elixir
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
|