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

View File

@@ -0,0 +1,316 @@
defmodule Berrypod.NewsletterTest do
use Berrypod.DataCase, async: true
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Newsletter
alias Berrypod.Newsletter.{Campaign, Subscriber}
import Berrypod.NewsletterFixtures
# ── Subscribe flow ───────────────────────────────────────────────
describe "subscribe/2" do
test "creates a pending subscriber" do
assert {:ok, %Subscriber{status: "pending"}} =
Newsletter.subscribe("test@example.com")
end
test "normalises email to lowercase" do
{:ok, sub} = Newsletter.subscribe("Test@EXAMPLE.com")
assert sub.email == "test@example.com"
end
test "stores consent text and source" do
{:ok, sub} =
Newsletter.subscribe("test@example.com",
consent_text: "Stay updated",
source: "website"
)
assert sub.consent_text == "Stay updated"
assert sub.source == "website"
end
test "stores hashed confirmation token" do
{:ok, sub} = Newsletter.subscribe("test@example.com")
sub = Repo.get!(Subscriber, sub.id)
assert sub.confirmation_token != nil
# Token is a hex-encoded SHA256 hash (64 chars)
assert byte_size(sub.confirmation_token) == 64
end
test "enqueues confirmation email worker" do
Oban.Testing.with_testing_mode(:manual, fn ->
{:ok, _sub} = Newsletter.subscribe("test@example.com")
assert_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
end)
end
test "returns already_confirmed for confirmed subscribers" do
sub = confirmed_subscriber_fixture(email: "test@example.com")
assert {:already_confirmed, ^sub} =
Newsletter.subscribe("test@example.com")
end
test "returns ok for pending subscribers (resends confirmation)" do
{:ok, sub} = Newsletter.subscribe("test@example.com")
assert {:ok, ^sub} = Newsletter.subscribe("test@example.com")
end
test "resubscribes unsubscribed users" do
confirmed_subscriber_fixture(email: "test@example.com")
{:ok, _} = Newsletter.unsubscribe("test@example.com")
{:ok, resub} = Newsletter.subscribe("test@example.com")
assert resub.status == "pending"
assert resub.unsubscribed_at == nil
end
test "rejects invalid email" do
assert {:error, %Ecto.Changeset{}} = Newsletter.subscribe("not-an-email")
end
end
# ── Confirm ──────────────────────────────────────────────────────
describe "confirm/1" do
test "confirms a pending subscriber with valid token" do
Oban.Testing.with_testing_mode(:manual, fn ->
{:ok, _sub} = Newsletter.subscribe("test@example.com")
# Grab the raw token from the enqueued Oban job
[job] = all_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
raw_token = job.args["raw_token"]
assert {:ok, confirmed} = Newsletter.confirm(raw_token)
assert confirmed.status == "confirmed"
assert confirmed.confirmed_at != nil
assert confirmed.confirmation_token == nil
end)
end
test "returns error for invalid token" do
assert {:error, :invalid_token} = Newsletter.confirm("bogus-token")
end
test "is idempotent for already confirmed subscribers" do
Oban.Testing.with_testing_mode(:manual, fn ->
{:ok, _sub} = Newsletter.subscribe("test@example.com")
[job] = all_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
raw_token = job.args["raw_token"]
{:ok, _} = Newsletter.confirm(raw_token)
# Token is cleared after first confirm, so second call with same token fails
assert {:error, :invalid_token} = Newsletter.confirm(raw_token)
end)
end
end
# ── Unsubscribe ──────────────────────────────────────────────────
describe "unsubscribe/1" do
test "marks subscriber as unsubscribed" do
confirmed_subscriber_fixture(email: "test@example.com")
assert {:ok, sub} = Newsletter.unsubscribe("test@example.com")
assert sub.status == "unsubscribed"
assert sub.unsubscribed_at != nil
end
test "does not add to global suppression list" do
confirmed_subscriber_fixture(email: "test@example.com")
Newsletter.unsubscribe("test@example.com")
assert Berrypod.Orders.check_suppression("test@example.com") == :ok
end
test "is idempotent" do
confirmed_subscriber_fixture(email: "test@example.com")
{:ok, _} = Newsletter.unsubscribe("test@example.com")
assert {:ok, _} = Newsletter.unsubscribe("test@example.com")
end
test "returns error for unknown email" do
assert {:error, :not_found} = Newsletter.unsubscribe("unknown@example.com")
end
end
# ── Subscriber queries ───────────────────────────────────────────
describe "list_subscribers/1" do
test "returns all subscribers" do
confirmed_subscriber_fixture()
confirmed_subscriber_fixture()
result = Newsletter.list_subscribers()
assert length(result) == 2
end
test "filters by status" do
confirmed_subscriber_fixture()
{:ok, _} = Newsletter.subscribe("pending@example.com")
result = Newsletter.list_subscribers(status: "confirmed")
assert length(result) == 1
assert hd(result).status == "confirmed"
end
test "searches by email" do
confirmed_subscriber_fixture(email: "alice@example.com")
confirmed_subscriber_fixture(email: "bob@example.com")
result = Newsletter.list_subscribers(search: "alice")
assert length(result) == 1
assert hd(result).email == "alice@example.com"
end
end
describe "count_subscribers_by_status/0" do
test "returns counts grouped by status" do
confirmed_subscriber_fixture()
confirmed_subscriber_fixture()
{:ok, _} = Newsletter.subscribe("pending@example.com")
counts = Newsletter.count_subscribers_by_status()
assert counts["confirmed"] == 2
assert counts["pending"] == 1
end
end
describe "confirmed_subscriber_count/0" do
test "returns count of confirmed subscribers" do
confirmed_subscriber_fixture()
confirmed_subscriber_fixture()
{:ok, _} = Newsletter.subscribe("pending@example.com")
assert Newsletter.confirmed_subscriber_count() == 2
end
end
describe "delete_subscriber/1" do
test "hard-deletes a subscriber" do
sub = confirmed_subscriber_fixture()
assert {:ok, _} = Newsletter.delete_subscriber(sub)
assert Repo.get(Subscriber, sub.id) == nil
end
end
describe "export_subscriber_data/1" do
test "returns a map of all stored data" do
sub = confirmed_subscriber_fixture(email: "export@example.com")
data = Newsletter.export_subscriber_data(sub)
assert data.email == "export@example.com"
assert data.status == "confirmed"
assert data.subscribed_at != nil
end
end
describe "export_all_subscribers_csv/0" do
test "returns CSV with header and rows" do
confirmed_subscriber_fixture(email: "a@example.com")
confirmed_subscriber_fixture(email: "b@example.com")
csv = Newsletter.export_all_subscribers_csv()
assert csv =~ "email,status,confirmed_at,subscribed_at"
assert csv =~ "a@example.com"
assert csv =~ "b@example.com"
end
end
# ── Campaign CRUD ────────────────────────────────────────────────
describe "campaign CRUD" do
test "creates a draft campaign" do
assert {:ok, %Campaign{status: "draft"}} =
Newsletter.create_campaign(%{
subject: "Test",
body: "Hello {{unsubscribe_url}}"
})
end
test "lists campaigns" do
campaign_fixture(subject: "First")
campaign_fixture(subject: "Second")
result = Newsletter.list_campaigns()
assert length(result) == 2
end
test "updates a campaign" do
campaign = campaign_fixture()
{:ok, updated} = Newsletter.update_campaign(campaign, %{subject: "Updated"})
assert updated.subject == "Updated"
end
test "deletes a draft campaign" do
campaign = campaign_fixture()
assert {:ok, _} = Newsletter.delete_campaign(campaign)
end
test "refuses to delete non-draft campaigns" do
campaign = campaign_fixture()
{:ok, sent} = Newsletter.update_campaign(campaign, %{status: "sent"})
assert {:error, :not_draft} = Newsletter.delete_campaign(sent)
end
end
describe "schedule_campaign/2" do
test "schedules a draft campaign" do
campaign = campaign_fixture()
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
assert {:ok, scheduled} = Newsletter.schedule_campaign(campaign, at)
assert scheduled.status == "scheduled"
assert scheduled.scheduled_at == at
end
test "refuses to schedule non-draft campaigns" do
campaign = campaign_fixture()
{:ok, sent} = Newsletter.update_campaign(campaign, %{status: "sent"})
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
assert {:error, :not_draft} = Newsletter.schedule_campaign(sent, at)
end
end
describe "cancel_campaign/1" do
test "cancels a scheduled campaign" do
campaign = campaign_fixture()
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
{:ok, scheduled} = Newsletter.schedule_campaign(campaign, at)
assert {:ok, cancelled} = Newsletter.cancel_campaign(scheduled)
assert cancelled.status == "cancelled"
end
test "refuses to cancel non-scheduled campaigns" do
campaign = campaign_fixture()
assert {:error, :not_scheduled} = Newsletter.cancel_campaign(campaign)
end
end
describe "preview_campaign/1" do
test "replaces unsubscribe placeholder" do
campaign = campaign_fixture(body: "Hello! Unsub: {{unsubscribe_url}}")
preview = Newsletter.preview_campaign(campaign)
assert preview =~ "/unsubscribe/sample-token"
refute preview =~ "{{unsubscribe_url}}"
end
end
# ── Settings ─────────────────────────────────────────────────────
describe "newsletter_enabled?/0" do
test "defaults to false" do
refute Newsletter.newsletter_enabled?()
end
test "returns true when enabled" do
Newsletter.set_newsletter_enabled(true)
assert Newsletter.newsletter_enabled?()
end
end
end