diff --git a/README.md b/README.md index a2b798b..22eac93 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,9 @@ local Codex CLI authenticated with their ChatGPT subscription. verified-contact status links, verification-gated support alerts, and audited staff queues. Support sends one initial response email and later public-status changes through the dedicated Oban `mail` queue; ordinary conversation - messages stay in the private inbox and optional push. + messages stay in the private inbox and optional push. Content-removal + confirmation, receipt, and public decision updates use the same bounded queue, + while internal assignment-only changes do not email the submitter. Authenticated users can download an allow-listed JSON data export that omits password/session/push credentials and counterpart message bodies. A moderator-only deletion preflight reports active workflows without performing diff --git a/docs/operations.md b/docs/operations.md index 5cd4563..7a4059e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1047,11 +1047,13 @@ The worker role consumes `maintenance`, `push`, and `mail`. Configure their per-worker limits with `OBAN_MAINTENANCE_CONCURRENCY`, `OBAN_PUSH_CONCURRENCY`, and `OBAN_MAIL_CONCURRENCY`; multiplying a value by the number of worker replicas gives the configured cluster-wide concurrency -for that queue. Authentication and mandatory legal-intake confirmations remain -synchronous. Ordinary support-update email runs in `mail`, so SMTP latency does -not occupy a web request or the `push` queue. Optional new-case operator alerts -also run in `mail`; requester conversation messages do not generate operator -email. +for that queue. Authentication links remain synchronous because their database +token is committed only when delivery succeeds. Support-contact verification, +support updates, content-removal confirmation/receipt/decision messages, and +optional new-case operator alerts run in `mail`, so their SMTP latency does not +occupy a web request or the `push` queue. Requester and staff conversation +messages do not each generate email, and internal assignment-only legal updates +do not notify the submitter. After starting the isolated load project, run an explicitly sized experiment: diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index 25fda22..54f21b5 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -115,6 +115,14 @@ specific review. public support contact is verified. Content-removal notification behavior is kept separate because those notices can require prompt legal or safety review. +All support-contact and content-removal confirmation, receipt, decision, and +optional operator-alert messages run through the dedicated Oban `mail` queue. +Authentication email remains a separate immediate path. A legal operator's +assignment or other internal-only change does not email the submitter; a public +status or decision-text change does. Queue uniqueness coalesces an undelivered +update to the latest persisted case state instead of sending one message for +each rapid edit. + The alert intentionally excludes the free-text report and reported URLs. The full record remains in the protected database queue. If the variable is empty, intake and reporter acknowledgement still work, but no operator inbox alert is diff --git a/docs/verification.md b/docs/verification.md index 1506f72..eede157 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -30,11 +30,14 @@ results from product limits and unknown production properties. response or a later public-status change, while authenticated requesters also receive an in-app support update. Ordinary requester and staff conversation messages do not each generate email. -- Optional support email runs through the dedicated Oban `mail` queue, whose - default concurrency is one per worker. Fixed-purpose delivery telemetry - records only the allow-listed purpose and outcome, without email addresses or - message content. Authentication and content-removal confirmations remain - transactional email paths by design. +- Support-contact verification, optional support updates, content-removal + confirmation/receipt/decision messages, and optional operator alerts run + through the dedicated Oban `mail` queue, whose default concurrency is one per + worker. Internal legal assignment-only changes do not email the submitter. + Fixed-purpose delivery telemetry records only the allow-listed purpose and + outcome, without email addresses or message content. Authentication links + remain synchronous by design because their database token is committed only + when delivery succeeds. - Exact-name inspection after completion found no container or temporary image belonging to quality scope `20260812133255-3403320` / `wnh_quality_202608121332553403320`. diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex index 26a9eda..c4ceb1d 100644 --- a/lib/who_need_help/content_removal.ex +++ b/lib/who_need_help/content_removal.ex @@ -6,6 +6,7 @@ defmodule WhoNeedHelp.ContentRemoval do alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.ContentRemoval.{Notice, Notifier} + alias WhoNeedHelp.Mail.ContentRemovalEmailWorker alias WhoNeedHelp.Pagination alias WhoNeedHelp.PublicAccess alias WhoNeedHelp.Repo @@ -63,11 +64,11 @@ defmodule WhoNeedHelp.ContentRemoval do "regime" => to_string(notice.regime), "status" => to_string(notice.status) } - ) do + ), + {:ok, _jobs} <- enqueue_created_emails(notice) do {:ok, notice} end end) - |> notify_received() end end @@ -198,7 +199,13 @@ defmodule WhoNeedHelp.ContentRemoval do |> Repo.one() if notice do - with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(), + changeset = Notice.moderation_changeset(notice, attrs) + + requester_update? = + Ecto.Changeset.changed?(changeset, :status) or + Ecto.Changeset.changed?(changeset, :resolution_note) + + with {:ok, notice} <- Repo.update(changeset), {:ok, _audit} <- Trust.audit( moderator.id, @@ -209,14 +216,15 @@ defmodule WhoNeedHelp.ContentRemoval do "status" => to_string(notice.status), "assigned_to_id" => notice.assigned_to_id } - ) do - {:ok, Repo.preload(notice, :assigned_to, force: true)} + ), + {:ok, _job} <- maybe_enqueue_update(notice, requester_update?) do + {:ok, {Repo.preload(notice, :assigned_to, force: true), requester_update?}} end else {:error, :not_found} end end) - |> notify_decision() + |> normalize_moderation_result() end else false -> {:error, :forbidden} @@ -224,52 +232,8 @@ defmodule WhoNeedHelp.ContentRemoval do end end - defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice}) - when is_binary(email) and email != "" do - case Notifier.deliver_confirmation(notice, confirmation_url(notice)) do - {:ok, _metadata} -> mark_acknowledgement_sent(notice) - _error -> {:ok, notice} - end - end - - defp notify_received({:ok, %Notice{contact_email: email} = notice}) when email in [nil, ""], - do: notify_operator(notice) - - defp notify_received({:ok, notice}) do - result = - case Notifier.deliver_received(notice, status_url(notice)) do - {:ok, _metadata} -> mark_acknowledgement_sent(notice) - _error -> {:ok, notice} - end - - case result do - {:ok, current} -> notify_operator(current) - error -> error - end - end - - defp notify_received(result), do: result - - defp notify_operator(notice) do - _ = Notifier.deliver_operator_alert(notice) - {:ok, notice} - end - - defp notify_decision({:ok, %Notice{contact_verified_at: nil} = notice}), do: {:ok, notice} - - defp notify_decision({:ok, notice}) do - case Notifier.deliver_decision(notice, status_url(notice)) do - {:ok, _metadata} -> - notice - |> Ecto.Changeset.change(decision_sent_at: DateTime.utc_now(:second)) - |> Repo.update() - - _error -> - {:ok, notice} - end - end - - defp notify_decision(result), do: result + defp normalize_moderation_result({:ok, {notice, _requester_update?}}), do: {:ok, notice} + defp normalize_moderation_result(result), do: result defp verify_contact(%Notice{contact_verified_at: nil} = notice) do Repo.transact(fn -> @@ -287,13 +251,13 @@ defmodule WhoNeedHelp.ContentRemoval do {:ok, {current, :already_verified}} true -> - current - |> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second)) - |> Repo.update() - |> then(fn - {:ok, verified} -> {:ok, {verified, :newly_verified}} - error -> error - end) + with {:ok, verified} <- + current + |> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second)) + |> Repo.update(), + {:ok, _jobs} <- enqueue_verified_emails(verified) do + {:ok, {verified, :newly_verified}} + end end end) |> notify_verified_notice() @@ -302,8 +266,6 @@ defmodule WhoNeedHelp.ContentRemoval do defp verify_contact(%Notice{} = notice), do: {:ok, notice} defp notify_verified_notice({:ok, {notice, :newly_verified}}) do - _ = Notifier.deliver_received(notice, status_url(notice)) - _ = Notifier.deliver_operator_alert(notice) {:ok, notice} end @@ -325,12 +287,46 @@ defmodule WhoNeedHelp.ContentRemoval do Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds) end - defp mark_acknowledgement_sent(notice) do - notice - |> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second)) - |> Repo.update() + defp enqueue_email(notice, kind) do + %{notice_id: notice.id, kind: to_string(kind)} + |> ContentRemovalEmailWorker.new() + |> Oban.insert() end + defp enqueue_created_emails(%Notice{contact_email: email, contact_verified_at: nil} = notice) + when is_binary(email) and email != "" do + with {:ok, confirmation} <- enqueue_email(notice, :confirmation) do + {:ok, [confirmation]} + end + end + + defp enqueue_created_emails(%Notice{} = notice), do: enqueue_verified_emails(notice) + + defp enqueue_verified_emails(%Notice{} = notice) do + with {:ok, received} <- maybe_enqueue_received(notice), + {:ok, operator} <- maybe_enqueue_operator(notice) do + {:ok, [received, operator]} + end + end + + defp maybe_enqueue_received(%Notice{contact_email: email} = notice) + when is_binary(email) and email != "", + do: enqueue_email(notice, :received) + + defp maybe_enqueue_received(%Notice{}), do: {:ok, :no_contact_email} + + defp maybe_enqueue_operator(%Notice{} = notice) do + if Notifier.operator_alerts_enabled?(), + do: enqueue_email(notice, :operator), + else: {:ok, :disabled} + end + + defp maybe_enqueue_update(%Notice{contact_verified_at: nil}, _requester_update?), + do: {:ok, :contact_not_verified} + + defp maybe_enqueue_update(%Notice{} = notice, true), do: enqueue_email(notice, :update) + defp maybe_enqueue_update(%Notice{}, false), do: {:ok, :not_needed} + defp visible_to_staff(query) do where( query, diff --git a/lib/who_need_help/content_removal/notifier.ex b/lib/who_need_help/content_removal/notifier.ex index 9cad703..4d7258b 100644 --- a/lib/who_need_help/content_removal/notifier.ex +++ b/lib/who_need_help/content_removal/notifier.ex @@ -93,6 +93,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do end end + def operator_alerts_enabled?, do: match?({:ok, _address}, operator_alert_address()) + defp deliver(recipient, subject, body, kind) do from = Application.fetch_env!(:who_need_help, :mailer_from) diff --git a/lib/who_need_help/email_delivery.ex b/lib/who_need_help/email_delivery.ex index 79d672f..45e2036 100644 --- a/lib/who_need_help/email_delivery.ex +++ b/lib/who_need_help/email_delivery.ex @@ -17,7 +17,6 @@ defmodule WhoNeedHelp.EmailDelivery do :content_removal_operator, :content_removal_received, :content_removal_update, - :nearby_alert, :support_confirmation, :support_operator, :support_update diff --git a/lib/who_need_help/mail/content_removal_email_worker.ex b/lib/who_need_help/mail/content_removal_email_worker.ex new file mode 100644 index 0000000..24eeb3a --- /dev/null +++ b/lib/who_need_help/mail/content_removal_email_worker.ex @@ -0,0 +1,74 @@ +defmodule WhoNeedHelp.Mail.ContentRemovalEmailWorker do + @moduledoc false + + use Oban.Worker, + queue: :mail, + max_attempts: 8, + unique: [ + period: :infinity, + fields: [:args, :worker], + keys: [:notice_id, :kind], + states: :incomplete + ] + + alias WhoNeedHelp.ContentRemoval + alias WhoNeedHelp.ContentRemoval.{Notice, Notifier} + alias WhoNeedHelp.Repo + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"notice_id" => notice_id, "kind" => kind}}) do + case Repo.get(Notice, notice_id) do + nil -> + {:cancel, :content_removal_notice_missing} + + %Notice{} = notice -> + deliver(notice, kind) + end + end + + defp deliver(%Notice{contact_verified_at: verified_at}, "confirmation") + when not is_nil(verified_at), + do: {:cancel, :contact_already_verified} + + defp deliver(%Notice{} = notice, "confirmation") do + notice + |> Notifier.deliver_confirmation(ContentRemoval.confirmation_url(notice)) + |> mark_sent(notice, :acknowledgement_sent_at) + end + + defp deliver(%Notice{contact_verified_at: nil}, kind) when kind in ["received", "update"], + do: {:cancel, :contact_not_verified} + + defp deliver(%Notice{} = notice, "received") do + notice + |> Notifier.deliver_received(ContentRemoval.status_url(notice)) + |> mark_sent(notice, :acknowledgement_sent_at) + end + + defp deliver(%Notice{} = notice, "update") do + notice + |> Notifier.deliver_decision(ContentRemoval.status_url(notice)) + |> mark_sent(notice, :decision_sent_at) + end + + defp deliver(%Notice{} = notice, "operator") do + case Notifier.deliver_operator_alert(notice) do + {:ok, _metadata} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp deliver(%Notice{}, _kind), do: {:cancel, :unknown_content_removal_email_kind} + + defp mark_sent({:ok, _metadata}, notice, field) do + notice + |> Ecto.Changeset.change([{field, DateTime.utc_now(:second)}]) + |> Repo.update() + |> case do + {:ok, _notice} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp mark_sent({:error, reason}, _notice, _field), do: {:error, reason} +end diff --git a/lib/who_need_help/mail/support_confirmation_worker.ex b/lib/who_need_help/mail/support_confirmation_worker.ex new file mode 100644 index 0000000..8f528c7 --- /dev/null +++ b/lib/who_need_help/mail/support_confirmation_worker.ex @@ -0,0 +1,34 @@ +defmodule WhoNeedHelp.Mail.SupportConfirmationWorker do + @moduledoc false + + use Oban.Worker, + queue: :mail, + max_attempts: 8, + unique: [ + period: :infinity, + fields: [:args, :worker], + keys: [:request_id], + states: :incomplete + ] + + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Support + alias WhoNeedHelp.Support.{Notifier, SupportRequest} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"request_id" => request_id}}) do + case Repo.get(SupportRequest, request_id) do + nil -> + {:cancel, :support_request_missing} + + %SupportRequest{contact_verified_at: verified_at} when not is_nil(verified_at) -> + {:cancel, :contact_already_verified} + + %SupportRequest{} = request -> + case Notifier.deliver_confirmation(request, Support.confirmation_url(request)) do + {:ok, _metadata} -> :ok + {:error, reason} -> {:error, reason} + end + end + end +end diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex index f24d5c5..97add85 100644 --- a/lib/who_need_help/support.ex +++ b/lib/who_need_help/support.ex @@ -6,7 +6,13 @@ defmodule WhoNeedHelp.Support do alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.DataLifecycle alias WhoNeedHelp.Accounts.{Scope, User} - alias WhoNeedHelp.Mail.{SupportOperatorAlertWorker, SupportUpdateWorker} + + alias WhoNeedHelp.Mail.{ + SupportConfirmationWorker, + SupportOperatorAlertWorker, + SupportUpdateWorker + } + alias WhoNeedHelp.Notifications alias WhoNeedHelp.Pagination alias WhoNeedHelp.PublicAccess @@ -70,14 +76,14 @@ defmodule WhoNeedHelp.Support do request.status, user && user.id, :requester - ) do + ), + {:ok, _job} <- enqueue_created_email(request) do {:ok, request} end end) result |> resolve_duplicate_submission(fingerprint) - |> notify_created() |> broadcast_request_update() end end @@ -257,14 +263,14 @@ defmodule WhoNeedHelp.Support do "status" => to_string(request.status), "assigned_to_id" => request.assigned_to_id } - ) do - {:ok, - {preload_conversation(request), - %{ - audit_id: audit.id, - message_added: match?(%ConversationMessage{}, message), - status_changed: previous_status != request.status - }}} + ), + update = %{ + audit_id: audit.id, + message_added: match?(%ConversationMessage{}, message), + status_changed: previous_status != request.status + }, + {:ok, _job} <- maybe_enqueue_support_email(request, update) do + {:ok, {preload_conversation(request), update}} end else {:error, :not_found} @@ -406,18 +412,13 @@ defmodule WhoNeedHelp.Support do end end - defp notify_created({:ok, {request, :pending_verification}}) do - _ = Notifier.deliver_confirmation(request, confirmation_url(request)) - {:ok, request} + defp enqueue_created_email(%SupportRequest{contact_verified_at: nil} = request) do + request.id + |> then(&SupportConfirmationWorker.new(%{request_id: &1})) + |> Oban.insert() end - defp notify_created({:ok, {request, :verified}}) do - _ = enqueue_operator_alert(request) - {:ok, request} - end - - defp notify_created({:ok, {request, :duplicate}}), do: {:ok, request} - defp notify_created(result), do: result + defp enqueue_created_email(%SupportRequest{} = request), do: enqueue_operator_alert(request) defp notify_decision({:ok, {%SupportRequest{contact_verified_at: nil} = request, _update}}), do: {:ok, request} @@ -427,10 +428,6 @@ defmodule WhoNeedHelp.Support do notify_requester_in_app(request, update) end - if support_email_needed?(request, update) do - _ = enqueue_support_email(request) - end - {:ok, request} end @@ -493,7 +490,8 @@ defmodule WhoNeedHelp.Support do "support_request.contact_verified", "support_request", verified.id - ) do + ), + {:ok, _job} <- enqueue_operator_alert(verified) do {:ok, {verified, :newly_verified}} end end @@ -504,8 +502,6 @@ defmodule WhoNeedHelp.Support do defp verify_contact(%SupportRequest{} = request), do: {:ok, request} defp notify_verified_request({:ok, {request, :newly_verified}}) do - _ = enqueue_operator_alert(request) - event = {:support_request_updated, request.id} Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event) Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, event) @@ -538,6 +534,14 @@ defmodule WhoNeedHelp.Support do update.status_changed or (update.message_added and is_nil(request.response_sent_at)) end + defp maybe_enqueue_support_email(request, update) do + if support_email_needed?(request, update) do + enqueue_support_email(request) + else + {:ok, :not_needed} + end + end + defp enqueue_support_email(request) do request.id |> then(&SupportUpdateWorker.new(%{request_id: &1})) @@ -757,15 +761,14 @@ defmodule WhoNeedHelp.Support do end defp resolve_duplicate_submission({:ok, request}, _fingerprint) do - outcome = if request.contact_verified_at, do: :verified, else: :pending_verification - {:ok, {request, outcome}} + {:ok, request} end defp resolve_duplicate_submission({:error, %Ecto.Changeset{} = changeset} = error, fingerprint) when is_binary(fingerprint) do if duplicate_fingerprint_error?(changeset) do case Repo.get_by(SupportRequest, public_submission_fingerprint: fingerprint) do - %SupportRequest{} = request -> {:ok, {request, :duplicate}} + %SupportRequest{} = request -> {:ok, request} nil -> error end else diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs index 684c281..2d62088 100644 --- a/test/who_need_help/support_and_content_removal_test.exs +++ b/test/who_need_help/support_and_content_removal_test.exs @@ -7,7 +7,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support} alias WhoNeedHelp.ContentRemoval.Notice - alias WhoNeedHelp.Mail.{SupportOperatorAlertWorker, SupportUpdateWorker} + + alias WhoNeedHelp.Mail.{ + ContentRemovalEmailWorker, + SupportConfirmationWorker, + SupportOperatorAlertWorker, + SupportUpdateWorker + } + alias WhoNeedHelp.Notifications.Notification alias WhoNeedHelp.Support.{ConversationMessage, StatusEvent, SupportRequest} alias WhoNeedHelp.Trust.AuditEvent @@ -27,6 +34,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do assert is_binary(request.public_submission_fingerprint) assert is_nil(request.contact_verified_at) + assert_enqueued( + worker: SupportConfirmationWorker, + queue: :mail, + args: %{"request_id" => request.id} + ) + + assert :ok = perform_job(SupportConfirmationWorker, %{"request_id" => request.id}) + test_pid = self() assert_email_sent(fn email -> @@ -103,6 +118,8 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do "Please review the moderation decision identified in my account notice." }) + assert :ok = perform_job(SupportConfirmationWorker, %{"request_id" => request.id}) + assert_email_sent(fn email -> email.to == [{"", "appeal@example.com"}] and email.reply_to == {"", "support@example.com"} and @@ -179,6 +196,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do } assert {:ok, first} = Support.create_request(nil, attrs) + assert :ok = perform_job(SupportConfirmationWorker, %{"request_id" => first.id}) assert_email_sent() assert {:ok, duplicate} = @@ -386,11 +404,98 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do assert {:ok, assigned_notice} = ContentRemoval.moderate(operator_scope, notice.id, %{ - "status" => "reviewing", + "status" => "open", "assigned_to_id" => legal_assignee.id }) assert assigned_notice.assigned_to_id == legal_assignee.id + + refute_enqueued( + worker: ContentRemovalEmailWorker, + args: %{"notice_id" => notice.id, "kind" => "update"} + ) + end + + test "content-removal mail is queued and internal assignment does not notify the submitter" do + requester = user_fixture() + assert_email_sent() + legal_operator = staff_user_fixture([:legal], display_name: "Legal operator") + assert_email_sent() + operator_scope = user_scope_fixture(legal_operator) + + assert {:ok, notice} = + ContentRemoval.create_notice(user_scope_fixture(requester), :general, %{ + "category" => "privacy_violation", + "submitter_name" => "Verified submitter", + "relationship" => "self", + "content_locations" => "https://example.test/requests/queued-legal-mail", + "explanation" => + "This notice verifies bounded asynchronous legal-notice email delivery.", + "electronic_signature" => "Verified submitter", + "good_faith" => "true", + "accurate_complete" => "true" + }) + + assert_enqueued( + worker: ContentRemovalEmailWorker, + queue: :mail, + args: %{"notice_id" => notice.id, "kind" => "received"} + ) + + refute_receive {:email, _synchronous_received_email}, 50 + + assert :ok = + perform_job(ContentRemovalEmailWorker, %{ + "notice_id" => notice.id, + "kind" => "received" + }) + + assert Repo.reload!(notice).acknowledgement_sent_at + + assert_email_sent(fn email -> + email.subject =~ notice.reference and email.text_body =~ "We received removal notice" + end) + + assert {:ok, assigned} = + ContentRemoval.moderate(operator_scope, notice.id, %{ + "status" => "open", + "assigned_to_id" => legal_operator.id + }) + + assert assigned.assigned_to_id == legal_operator.id + + refute_enqueued( + worker: ContentRemovalEmailWorker, + args: %{"notice_id" => notice.id, "kind" => "update"} + ) + + assert {:ok, updated} = + ContentRemoval.moderate(operator_scope, notice.id, %{ + "status" => "needs_information", + "assigned_to_id" => legal_operator.id, + "resolution_note" => "Please provide one exact public URL for review." + }) + + assert updated.status == :needs_information + + assert_enqueued( + worker: ContentRemovalEmailWorker, + queue: :mail, + args: %{"notice_id" => notice.id, "kind" => "update"} + ) + + assert :ok = + perform_job(ContentRemovalEmailWorker, %{ + "notice_id" => notice.id, + "kind" => "update" + }) + + assert Repo.reload!(updated).decision_sent_at + + assert_email_sent(fn email -> + email.subject =~ "removal notice update" and + email.text_body =~ "Please provide one exact public URL for review." + end) end test "requester replies reopen a finished case and preserve the full conversation history" do @@ -515,6 +620,18 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do assert notice.response_due_at assert is_nil(notice.contact_verified_at) + assert_enqueued( + worker: ContentRemovalEmailWorker, + queue: :mail, + args: %{"notice_id" => notice.id, "kind" => "confirmation"} + ) + + assert :ok = + perform_job(ContentRemovalEmailWorker, %{ + "notice_id" => notice.id, + "kind" => "confirmation" + }) + assert_email_sent(fn email -> email.subject =~ "Confirm Who Need Help removal notice" and email.text_body =~ notice.reference and @@ -538,6 +655,12 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do assert verified.contact_verified_at + assert_enqueued( + worker: ContentRemovalEmailWorker, + queue: :mail, + args: %{"notice_id" => notice.id, "kind" => "received"} + ) + assert Enum.map(ContentRemoval.paginate_for_staff(moderator_scope).entries, & &1.id) == [ notice.id ] diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs index 5220141..0d37ebc 100644 --- a/test/who_need_help_web/controllers/support_controller_test.exs +++ b/test/who_need_help_web/controllers/support_controller_test.exs @@ -1,11 +1,13 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do use WhoNeedHelpWeb.ConnCase, async: false + use Oban.Testing, repo: WhoNeedHelp.Repo import Phoenix.LiveViewTest import WhoNeedHelp.AccountsFixtures import Swoosh.TestAssertions alias WhoNeedHelp.Repo + alias WhoNeedHelp.Mail.{ContentRemovalEmailWorker, SupportConfirmationWorker} alias WhoNeedHelp.Support.SupportRequest describe "public support and removal intake" do @@ -66,6 +68,18 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do assert location =~ "/support/received?reference=SUP-" refute location =~ "token=" + request = Repo.get_by!(SupportRequest, contact_email: "person@example.com") + + assert_enqueued( + worker: SupportConfirmationWorker, + queue: :mail, + args: %{"request_id" => request.id} + ) + + refute_receive {:email, _synchronous_confirmation}, 50 + + assert :ok = perform_job(SupportConfirmationWorker, %{"request_id" => request.id}) + assert_email_sent(fn email -> email.to == [{"", "person@example.com"}] and email.subject =~ "Confirm Who Need Help support request" @@ -134,6 +148,26 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do location = redirected_to(conn) assert location =~ "/legal/content-removal/received?reference=REM-" refute location =~ "token=" + + notice = + Repo.get_by!(WhoNeedHelp.ContentRemoval.Notice, + contact_email: "affected@example.com" + ) + + assert_enqueued( + worker: ContentRemovalEmailWorker, + queue: :mail, + args: %{"notice_id" => notice.id, "kind" => "confirmation"} + ) + + refute_receive {:email, _synchronous_confirmation}, 50 + + assert :ok = + perform_job(ContentRemovalEmailWorker, %{ + "notice_id" => notice.id, + "kind" => "confirmation" + }) + assert_email_sent() end