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:
jamey
2026-02-28 23:25:28 +00:00
parent 8f989d892d
commit ad2e6d1e6d
32 changed files with 2497 additions and 32 deletions

View 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

View 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

View 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