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
|
||||
316
test/berrypod/newsletter_test.exs
Normal file
316
test/berrypod/newsletter_test.exs
Normal 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
|
||||
96
test/berrypod_web/controllers/newsletter_controller_test.exs
Normal file
96
test/berrypod_web/controllers/newsletter_controller_test.exs
Normal file
@@ -0,0 +1,96 @@
|
||||
defmodule BerrypodWeb.NewsletterControllerTest do
|
||||
use BerrypodWeb.ConnCase, async: true
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.Subscriber
|
||||
alias Berrypod.Repo
|
||||
|
||||
describe "POST /newsletter/subscribe" do
|
||||
test "subscribes and redirects back", %{conn: conn} do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("referer", "http://localhost:4002/")
|
||||
|> post(~p"/newsletter/subscribe", %{email: "new@example.com"})
|
||||
|
||||
assert redirected_to(conn) == "/"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Check your inbox"
|
||||
end
|
||||
|
||||
test "handles already confirmed subscriber", %{conn: conn} do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
|
||||
# Create a confirmed subscriber directly
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
%Subscriber{}
|
||||
|> Subscriber.changeset(%{
|
||||
email: "confirmed@example.com",
|
||||
status: "confirmed",
|
||||
confirmed_at: now
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("referer", "http://localhost:4002/about")
|
||||
|> post(~p"/newsletter/subscribe", %{email: "confirmed@example.com"})
|
||||
|
||||
assert redirected_to(conn) == "/about"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "already subscribed"
|
||||
end
|
||||
|
||||
test "shows error for invalid email", %{conn: conn} do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("referer", "http://localhost:4002/")
|
||||
|> post(~p"/newsletter/subscribe", %{email: "not-an-email"})
|
||||
|
||||
assert redirected_to(conn) == "/"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "valid email"
|
||||
end
|
||||
|
||||
test "redirects to / when no referer", %{conn: conn} do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
|
||||
conn = post(conn, ~p"/newsletter/subscribe", %{email: "test@example.com"})
|
||||
|
||||
assert redirected_to(conn) == "/"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /newsletter/confirm/:token" do
|
||||
test "confirms pending subscriber", %{conn: conn} do
|
||||
# Create a pending subscriber with a known raw token
|
||||
raw_token = "test-confirmation-token-abc123"
|
||||
hashed_token = :crypto.hash(:sha256, raw_token) |> Base.encode16(case: :lower)
|
||||
|
||||
%Subscriber{}
|
||||
|> Subscriber.changeset(%{
|
||||
email: "pending@example.com",
|
||||
status: "pending",
|
||||
confirmation_token: hashed_token
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
conn = get(conn, "/newsletter/confirm/#{raw_token}")
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body =~ "subscribed"
|
||||
|
||||
# Verify the subscriber is now confirmed
|
||||
sub = Repo.get_by!(Subscriber, email: "pending@example.com")
|
||||
assert sub.status == "confirmed"
|
||||
end
|
||||
|
||||
test "returns error for invalid token", %{conn: conn} do
|
||||
conn = get(conn, "/newsletter/confirm/invalid-token-here")
|
||||
|
||||
assert conn.status == 400
|
||||
assert conn.resp_body =~ "invalid"
|
||||
end
|
||||
end
|
||||
end
|
||||
131
test/berrypod_web/live/admin/newsletter_test.exs
Normal file
131
test/berrypod_web/live/admin/newsletter_test.exs
Normal file
@@ -0,0 +1,131 @@
|
||||
defmodule BerrypodWeb.Admin.NewsletterTest do
|
||||
use BerrypodWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Berrypod.AccountsFixtures
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
setup %{conn: conn} do
|
||||
user = user_fixture()
|
||||
conn = log_in_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
describe "overview tab" do
|
||||
test "shows newsletter toggle", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter")
|
||||
|
||||
assert has_element?(view, "[role=switch]")
|
||||
end
|
||||
|
||||
test "toggles newsletter enabled", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter")
|
||||
|
||||
refute Newsletter.newsletter_enabled?()
|
||||
|
||||
view |> element("[role=switch]") |> render_click()
|
||||
|
||||
assert Newsletter.newsletter_enabled?()
|
||||
end
|
||||
|
||||
test "shows subscriber counts", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "a@example.com")
|
||||
confirmed_subscriber_fixture(email: "b@example.com")
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/newsletter")
|
||||
|
||||
assert html =~ "2"
|
||||
assert html =~ "Confirmed"
|
||||
end
|
||||
end
|
||||
|
||||
describe "subscribers tab" do
|
||||
test "lists subscribers", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "listed@example.com")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||
|
||||
assert has_element?(view, "#subscribers")
|
||||
assert render(view) =~ "listed@example.com"
|
||||
end
|
||||
|
||||
test "filters by status", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "confirmed@example.com")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||
|
||||
# Filter to confirmed only
|
||||
view |> element("button", "Confirmed") |> render_click()
|
||||
|
||||
assert render(view) =~ "confirmed@example.com"
|
||||
end
|
||||
|
||||
test "searches subscribers", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "findme@example.com")
|
||||
confirmed_subscriber_fixture(email: "other@example.com")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||
|
||||
view |> element("form") |> render_change(%{search: "findme"})
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "findme@example.com"
|
||||
refute html =~ "other@example.com"
|
||||
end
|
||||
|
||||
test "deletes subscriber", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "deleteme@example.com")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||
|
||||
assert render(view) =~ "deleteme@example.com"
|
||||
|
||||
view |> element("button", "Delete") |> render_click()
|
||||
|
||||
refute render(view) =~ "deleteme@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "campaigns tab" do
|
||||
test "lists campaigns", %{conn: conn} do
|
||||
campaign_fixture(subject: "Test campaign")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||
|
||||
assert render(view) =~ "Test campaign"
|
||||
end
|
||||
|
||||
test "deletes draft campaign", %{conn: conn} do
|
||||
campaign_fixture(subject: "Delete me")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||
|
||||
assert render(view) =~ "Delete me"
|
||||
|
||||
view |> element("button", "Delete") |> render_click()
|
||||
|
||||
refute render(view) =~ "Delete me"
|
||||
end
|
||||
|
||||
test "links to new campaign form", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||
|
||||
assert has_element?(view, "a[href='/admin/newsletter/campaigns/new']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /admin/newsletter/export" do
|
||||
test "downloads CSV of subscribers", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "export@example.com")
|
||||
|
||||
conn = get(conn, ~p"/admin/newsletter/export")
|
||||
|
||||
assert response_content_type(conn, :csv) =~ "text/csv"
|
||||
assert get_resp_header(conn, "content-disposition") |> hd() =~ "attachment"
|
||||
assert conn.resp_body =~ "export@example.com"
|
||||
assert conn.resp_body =~ "email,status"
|
||||
end
|
||||
end
|
||||
end
|
||||
40
test/support/fixtures/newsletter_fixtures.ex
Normal file
40
test/support/fixtures/newsletter_fixtures.ex
Normal file
@@ -0,0 +1,40 @@
|
||||
defmodule Berrypod.NewsletterFixtures do
|
||||
@moduledoc """
|
||||
Test helpers for creating newsletter subscribers and campaigns.
|
||||
"""
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
def unique_email, do: "user#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
@doc """
|
||||
Creates a confirmed subscriber directly via Repo, bypassing the
|
||||
confirmation email flow.
|
||||
"""
|
||||
def confirmed_subscriber_fixture(attrs \\ %{}) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
email: unique_email(),
|
||||
status: "confirmed",
|
||||
confirmed_at: now,
|
||||
consent_text: "Test signup"
|
||||
})
|
||||
|
||||
%Newsletter.Subscriber{}
|
||||
|> Newsletter.Subscriber.changeset(attrs)
|
||||
|> Berrypod.Repo.insert!()
|
||||
end
|
||||
|
||||
def campaign_fixture(attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
subject: "Test campaign #{System.unique_integer([:positive])}",
|
||||
body: "Hello! Unsubscribe: {{unsubscribe_url}}"
|
||||
})
|
||||
|
||||
{:ok, campaign} = Newsletter.create_campaign(attrs)
|
||||
campaign
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user