add newsletter and email campaigns
Subscribers with double opt-in confirmation, campaign composer with draft/scheduled/sent lifecycle, admin dashboard with overview stats, CSV export, and shop signup form wired into page builder blocks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
50
test/berrypod/newsletter/campaign_send_worker_test.exs
Normal file
50
test/berrypod/newsletter/campaign_send_worker_test.exs
Normal file
@@ -0,0 +1,50 @@
|
||||
defmodule Berrypod.Newsletter.CampaignSendWorkerTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
use Oban.Testing, repo: Berrypod.Repo
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.CampaignSendWorker
|
||||
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
describe "perform/1" do
|
||||
test "sends campaign to confirmed subscribers" do
|
||||
confirmed_subscriber_fixture(email: "a@example.com")
|
||||
confirmed_subscriber_fixture(email: "b@example.com")
|
||||
|
||||
campaign = campaign_fixture(subject: "Hello!", body: "Test body {{unsubscribe_url}}")
|
||||
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sending"})
|
||||
|
||||
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||
|
||||
campaign = Newsletter.get_campaign!(campaign.id)
|
||||
assert campaign.status == "sent"
|
||||
assert campaign.sent_count == 2
|
||||
assert campaign.failed_count == 0
|
||||
assert campaign.sent_at != nil
|
||||
end
|
||||
|
||||
test "only sends to confirmed subscribers" do
|
||||
confirmed_subscriber_fixture(email: "active@example.com")
|
||||
|
||||
# Create an unsubscribed subscriber — should be skipped
|
||||
sub = confirmed_subscriber_fixture(email: "unsub@example.com")
|
||||
Newsletter.unsubscribe(sub.email)
|
||||
|
||||
campaign = campaign_fixture()
|
||||
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sending"})
|
||||
|
||||
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||
|
||||
campaign = Newsletter.get_campaign!(campaign.id)
|
||||
assert campaign.sent_count == 1
|
||||
end
|
||||
|
||||
test "skips campaigns with wrong status" do
|
||||
campaign = campaign_fixture()
|
||||
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sent"})
|
||||
|
||||
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||
end
|
||||
end
|
||||
end
|
||||
48
test/berrypod/newsletter/cleanup_worker_test.exs
Normal file
48
test/berrypod/newsletter/cleanup_worker_test.exs
Normal file
@@ -0,0 +1,48 @@
|
||||
defmodule Berrypod.Newsletter.CleanupWorkerTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
use Oban.Testing, repo: Berrypod.Repo
|
||||
|
||||
alias Berrypod.Newsletter.{CleanupWorker, Subscriber}
|
||||
alias Berrypod.Repo
|
||||
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
describe "perform/1" do
|
||||
test "prunes pending subscribers older than 48 hours" do
|
||||
# Insert an old pending subscriber directly
|
||||
old_time = DateTime.utc_now() |> DateTime.add(-49 * 3600) |> DateTime.truncate(:second)
|
||||
|
||||
%Subscriber{}
|
||||
|> Subscriber.changeset(%{email: "old@example.com", status: "pending"})
|
||||
|> Ecto.Changeset.force_change(:inserted_at, old_time)
|
||||
|> Repo.insert!()
|
||||
|
||||
# Insert a fresh pending subscriber
|
||||
%Subscriber{}
|
||||
|> Subscriber.changeset(%{email: "fresh@example.com", status: "pending"})
|
||||
|> Repo.insert!()
|
||||
|
||||
assert :ok = perform_job(CleanupWorker, %{})
|
||||
|
||||
# Old pending subscriber should be deleted
|
||||
assert Repo.get_by(Subscriber, email: "old@example.com") == nil
|
||||
# Fresh one should remain
|
||||
assert Repo.get_by(Subscriber, email: "fresh@example.com") != nil
|
||||
end
|
||||
|
||||
test "does not prune confirmed subscribers" do
|
||||
old_time = DateTime.utc_now() |> DateTime.add(-49 * 3600) |> DateTime.truncate(:second)
|
||||
|
||||
confirmed = confirmed_subscriber_fixture(email: "confirmed@example.com")
|
||||
|
||||
# Backdate the subscriber
|
||||
confirmed
|
||||
|> Ecto.Changeset.change(inserted_at: old_time)
|
||||
|> Repo.update!()
|
||||
|
||||
assert :ok = perform_job(CleanupWorker, %{})
|
||||
|
||||
assert Repo.get_by(Subscriber, email: "confirmed@example.com") != nil
|
||||
end
|
||||
end
|
||||
end
|
||||
45
test/berrypod/newsletter/confirmation_email_worker_test.exs
Normal file
45
test/berrypod/newsletter/confirmation_email_worker_test.exs
Normal file
@@ -0,0 +1,45 @@
|
||||
defmodule Berrypod.Newsletter.ConfirmationEmailWorkerTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
use Oban.Testing, repo: Berrypod.Repo
|
||||
|
||||
alias Berrypod.Newsletter.ConfirmationEmailWorker
|
||||
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
describe "perform/1" do
|
||||
test "sends confirmation email for pending subscriber" do
|
||||
# Create subscriber in manual mode so the confirmation job doesn't auto-run
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
{:ok, sub} = Berrypod.Newsletter.subscribe("test@example.com")
|
||||
|
||||
[job] = all_enqueued(worker: ConfirmationEmailWorker)
|
||||
raw_token = job.args["raw_token"]
|
||||
|
||||
# Now perform the job (uses Swoosh.Adapters.Local in test)
|
||||
assert :ok =
|
||||
perform_job(ConfirmationEmailWorker, %{
|
||||
subscriber_id: sub.id,
|
||||
raw_token: raw_token
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
test "skips if subscriber is no longer pending" do
|
||||
sub = confirmed_subscriber_fixture()
|
||||
|
||||
assert :ok =
|
||||
perform_job(ConfirmationEmailWorker, %{
|
||||
subscriber_id: sub.id,
|
||||
raw_token: "irrelevant"
|
||||
})
|
||||
end
|
||||
|
||||
test "cancels if subscriber not found" do
|
||||
assert {:cancel, :not_found} =
|
||||
perform_job(ConfirmationEmailWorker, %{
|
||||
subscriber_id: Ecto.UUID.generate(),
|
||||
raw_token: "irrelevant"
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user