berrypod/test/berrypod/activity_log_test.exs
jamey 580a7203c9
All checks were successful
deploy / deploy (push) Successful in 4m22s
add activity log with order timeline and global feed
Single activity_log table powering two views: chronological timeline
on each order detail page (replacing the old fulfilment card) and a
global feed at /admin/activity with tabs, category filters, search,
and pagination. Real-time via PubSub — new entries appear instantly,
nav badge updates across all admin pages.

Instrumented across all event points: Stripe webhooks, order notifier,
submission worker, fulfilment status worker, product sync worker, and
Oban exhausted-job telemetry. Contextual action buttons (retry
submission, retry sync, dismiss) with Oban unique constraints to
prevent double-enqueue. 90-day pruning via cron.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:09:08 +00:00

300 lines
9.3 KiB
Elixir

defmodule Berrypod.ActivityLogTest do
use Berrypod.DataCase, async: false
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.ActivityLog
alias Berrypod.ActivityLog.Entry
import Berrypod.OrdersFixtures
# ── log_event/3 ──
describe "log_event/3" do
test "creates an entry with correct fields" do
{:ok, entry} = ActivityLog.log_event("sync.completed", "Product sync complete")
assert entry.event_type == "sync.completed"
assert entry.message == "Product sync complete"
assert entry.level == "info"
assert entry.occurred_at
end
test "accepts level, order_id, payload, and occurred_at options" do
order = order_fixture(%{payment_status: "paid"})
ts = ~U[2026-02-15 10:00:00Z]
{:ok, entry} =
ActivityLog.log_event("order.submitted", "Submitted to Printify",
level: "warning",
order_id: order.id,
payload: %{provider_order_id: "PFY-123"},
occurred_at: ts
)
assert entry.level == "warning"
assert entry.order_id == order.id
assert entry.payload == %{provider_order_id: "PFY-123"}
assert entry.occurred_at == ts
end
test "defaults level to info and occurred_at to now" do
{:ok, entry} = ActivityLog.log_event("sync.started", "Starting sync")
assert entry.level == "info"
assert DateTime.diff(DateTime.utc_now(), entry.occurred_at, :second) < 2
end
test "never raises on invalid attrs" do
# missing required message (nil)
result = ActivityLog.log_event("test.event", nil)
assert result == :ok
end
test "broadcasts on activity:all topic" do
ActivityLog.subscribe()
{:ok, entry} = ActivityLog.log_event("sync.started", "Starting sync")
assert_receive {:new_activity, ^entry}
end
test "broadcasts on activity:order:<id> when order_id present" do
order = order_fixture(%{payment_status: "paid"})
ActivityLog.subscribe(order.id)
{:ok, entry} =
ActivityLog.log_event("order.submitted", "Submitted", order_id: order.id)
assert_receive {:new_activity, ^entry}
end
test "does not broadcast on order topic when no order_id" do
ActivityLog.subscribe()
{:ok, _entry} = ActivityLog.log_event("sync.started", "Starting sync")
assert_receive {:new_activity, _}
# only the global topic message, no order-specific one
end
end
# ── list_for_order/1 ──
describe "list_for_order/1" do
test "returns entries for a specific order sorted ASC by occurred_at" do
order = order_fixture(%{payment_status: "paid"})
{:ok, _} =
ActivityLog.log_event("order.created", "Order placed",
order_id: order.id,
occurred_at: ~U[2026-02-15 10:00:00Z]
)
{:ok, _} =
ActivityLog.log_event("order.submitted", "Submitted",
order_id: order.id,
occurred_at: ~U[2026-02-15 10:01:00Z]
)
{:ok, _} =
ActivityLog.log_event("order.shipped", "Shipped",
order_id: order.id,
occurred_at: ~U[2026-02-15 12:00:00Z]
)
entries = ActivityLog.list_for_order(order.id)
assert length(entries) == 3
assert Enum.map(entries, & &1.event_type) == ~w(order.created order.submitted order.shipped)
end
test "returns empty list for order with no events" do
assert ActivityLog.list_for_order(Ecto.UUID.generate()) == []
end
test "does not include events from other orders" do
order1 = order_fixture(%{payment_status: "paid"})
order2 = order_fixture(%{payment_status: "paid"})
{:ok, _} = ActivityLog.log_event("order.created", "O1", order_id: order1.id)
{:ok, _} = ActivityLog.log_event("order.created", "O2", order_id: order2.id)
assert length(ActivityLog.list_for_order(order1.id)) == 1
assert length(ActivityLog.list_for_order(order2.id)) == 1
end
end
# ── list_recent/1 ──
describe "list_recent/1" do
test "returns paginated entries newest first" do
for i <- 1..3 do
{:ok, _} =
ActivityLog.log_event("sync.completed", "Sync #{i}",
occurred_at: DateTime.add(~U[2026-02-15 10:00:00Z], i, :minute)
)
end
page = ActivityLog.list_recent()
assert page.total_count == 3
messages = Enum.map(page.items, & &1.message)
assert List.first(messages) == "Sync 3"
assert List.last(messages) == "Sync 1"
end
test "filters by attention tab" do
{:ok, _} = ActivityLog.log_event("sync.completed", "OK")
{:ok, _} = ActivityLog.log_event("sync.failed", "Failed", level: "error")
page = ActivityLog.list_recent(tab: "attention")
assert page.total_count == 1
assert hd(page.items).level == "error"
end
test "filters by orders category" do
{:ok, _} = ActivityLog.log_event("order.created", "Order placed")
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
page = ActivityLog.list_recent(category: "orders")
assert page.total_count == 1
assert hd(page.items).event_type == "order.created"
end
test "filters by syncs category" do
{:ok, _} = ActivityLog.log_event("order.created", "Order placed")
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
page = ActivityLog.list_recent(category: "syncs")
assert page.total_count == 1
assert hd(page.items).event_type == "sync.completed"
end
test "filters by emails category" do
{:ok, _} = ActivityLog.log_event("order.email.confirmation_sent", "Email sent")
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
page = ActivityLog.list_recent(category: "emails")
assert page.total_count == 1
assert hd(page.items).event_type == "order.email.confirmation_sent"
end
test "filters by carts category" do
{:ok, _} = ActivityLog.log_event("abandoned_cart.created", "Cart abandoned")
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
page = ActivityLog.list_recent(category: "carts")
assert page.total_count == 1
assert hd(page.items).event_type == "abandoned_cart.created"
end
test "searches by order number" do
order = order_fixture(%{payment_status: "paid"})
{:ok, _} = ActivityLog.log_event("order.created", "Order placed", order_id: order.id)
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
# Extract enough of the order number to match
search = String.slice(order.order_number, 3, 6)
page = ActivityLog.list_recent(search: search)
assert page.total_count == 1
assert hd(page.items).order_id == order.id
end
end
# ── count_needing_attention/0 ──
describe "count_needing_attention/0" do
test "counts unresolved warnings and errors" do
{:ok, _} = ActivityLog.log_event("sync.failed", "Error", level: "error")
{:ok, _} = ActivityLog.log_event("order.cancelled", "Cancelled", level: "warning")
{:ok, _} = ActivityLog.log_event("sync.completed", "OK")
assert ActivityLog.count_needing_attention() == 2
end
test "returns 0 when all resolved" do
{:ok, entry} = ActivityLog.log_event("sync.failed", "Error", level: "error")
ActivityLog.resolve(entry.id)
assert ActivityLog.count_needing_attention() == 0
end
end
# ── resolve/1 ──
describe "resolve/1" do
test "sets resolved_at on an entry" do
{:ok, entry} = ActivityLog.log_event("sync.failed", "Error", level: "error")
assert is_nil(entry.resolved_at)
{1, _} = ActivityLog.resolve(entry.id)
updated = Repo.get!(Entry, entry.id)
refute is_nil(updated.resolved_at)
end
end
# ── resolve_all_for_order/1 ──
describe "resolve_all_for_order/1" do
test "resolves all unresolved entries for an order" do
order = order_fixture(%{payment_status: "paid"})
{:ok, _} =
ActivityLog.log_event("order.submission_failed", "Failed",
level: "error",
order_id: order.id
)
{:ok, _} =
ActivityLog.log_event("order.cancelled", "Cancelled",
level: "warning",
order_id: order.id
)
{2, _} = ActivityLog.resolve_all_for_order(order.id)
entries = ActivityLog.list_for_order(order.id)
assert Enum.all?(entries, fn e -> not is_nil(e.resolved_at) end)
end
end
# ── PruneWorker ──
describe "PruneWorker" do
test "deletes entries older than 90 days" do
old_time = DateTime.utc_now() |> DateTime.add(-91, :day) |> DateTime.truncate(:second)
recent_time = DateTime.utc_now() |> DateTime.add(-1, :day) |> DateTime.truncate(:second)
# Insert old entry directly to control inserted_at
Repo.insert!(%Entry{
event_type: "sync.completed",
message: "Old sync",
level: "info",
occurred_at: old_time,
inserted_at: old_time,
updated_at: old_time
})
Repo.insert!(%Entry{
event_type: "sync.completed",
message: "Recent sync",
level: "info",
occurred_at: recent_time,
inserted_at: recent_time,
updated_at: recent_time
})
assert Repo.aggregate(Entry, :count) == 2
:ok = perform_job(Berrypod.ActivityLog.PruneWorker, %{})
entries = Repo.all(Entry)
assert length(entries) == 1
assert hd(entries).message == "Recent sync"
end
end
end