All checks were successful
deploy / deploy (push) Successful in 4m22s
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>
300 lines
9.3 KiB
Elixir
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
|