discourse/spec/services/admin_dashboard_highlights_spec.rb
Natalie Tay 728e70f507
FEATURE: Admin dashboard highlights to real data (#39895)
Behind the `dashboard_improvements` setting, the Highlights row is
hardcoded

This commit updates `/admin/dashboard.json` to swap out to a
`sections.highlights`. The endpoint takes optional `start_date` /
`end_date` so the redesigned page passes its picker range. Legacy
dashboard / `AdminDashboard.fetch()` callers get the same response
without the new stuff.

Core ships three KPIs (`new_signups`, `dau_mau`, `new_contributors`).
Plugins register more via a new
`register_admin_dashboard_highlight_kpi(type:, report:, enabled:)`
(discourse-solved for now). Each plugin owns their own KPI stuff.

Caching uses the existing per-report layer (`Report.find_cached`) rather
than wrapping the service in its own cache. Toggling a plugin's
`enabled:` takes effect immediately, the cache is shared with the
Reports tab when an admin drills into a tile, and the cache key isn't
fragmented per admin since the data is admin-agnostic.

The server returns `report_type` + `report_query` instead of a full URL
so routes are stable.

Without solved:

https://github.com/user-attachments/assets/28612829-b425-4664-bfb9-02e8783f5b93

With solved:

https://github.com/user-attachments/assets/3c6b6d6f-44b7-4c61-9b23-0aaaa3f21ddc
2026-05-12 13:15:52 +08:00

196 lines
7.8 KiB
Ruby
Vendored

# frozen_string_literal: true
describe AdminDashboardHighlights do
describe ".build" do
before do
freeze_time(Time.zone.local(2026, 4, 28, 12, 0, 0))
Discourse.cache.clear
end
it "returns a kpis array keyed by report type" do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:kpis]).to be_an(Array)
types = result[:kpis].map { |k| k[:type] }
expect(types).to include(:new_signups, :dau_mau, :new_contributors)
end
it "computes value, previous_value and percent_change for new_signups" do
Fabricate(:user, created_at: Time.zone.local(2026, 4, 10))
Fabricate(:user, created_at: Time.zone.local(2026, 4, 15))
Fabricate(:user, created_at: Time.zone.local(2026, 3, 10))
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
signups = result[:kpis].find { |k| k[:type] == :new_signups }
expect(signups[:value]).to eq(2)
expect(signups[:previous_value]).to eq(1)
expect(signups[:percent_change]).to eq(100.0)
end
it "emits report_type and report_query instead of a synthesised URL" do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
signups = result[:kpis].find { |k| k[:type] == :new_signups }
expect(signups[:report_type]).to eq("signups")
expect(signups[:report_query]).to eq(start_date: "2026-04-01", end_date: "2026-04-28")
expect(signups).not_to have_key(:report_url)
end
it "returns nil percent_change when previous is zero" do
Fabricate(:user, created_at: Time.zone.local(2026, 4, 10))
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
signups = result[:kpis].find { |k| k[:type] == :new_signups }
expect(signups[:percent_change]).to be_nil
end
it "returns nil value when the report has no data points" do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
dau = result[:kpis].find { |k| k[:type] == :dau_mau }
expect(dau[:value]).to be_nil
end
it "averages dau_mau values rather than summing them when there is data" do
Fabricate(:user_visit, visited_at: Time.zone.local(2026, 4, 10).to_date)
Fabricate(:user_visit, visited_at: Time.zone.local(2026, 4, 15).to_date)
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
dau = result[:kpis].find { |k| k[:type] == :dau_mau }
expect(dau[:value]).to be_a(Float)
end
describe "plugin-registered KPIs" do
let(:kpi_entry) { { type: :extra_kpi, report: "signups", enabled: -> { @kpi_enabled } } }
before { DiscoursePluginRegistry.stubs(:admin_dashboard_highlight_kpis).returns([kpi_entry]) }
it "includes a registered KPI when its enabled proc returns true" do
@kpi_enabled = true
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:kpis].map { |k| k[:type] }).to include(:extra_kpi)
end
it "omits a registered KPI when its enabled proc returns false" do
@kpi_enabled = false
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:kpis].map { |k| k[:type] }).not_to include(:extra_kpi)
end
end
it "skips a KPI when its report errors out" do
original = Report.method(:find)
Report.define_singleton_method(:find) do |type, *args, **kwargs|
if type == "signups"
r = original.call(type, *args, **kwargs)
r.error = :timeout
r
else
original.call(type, *args, **kwargs)
end
end
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:kpis].map { |k| k[:type] }).not_to include(:new_signups)
ensure
Report.define_singleton_method(:find, &original)
end
it "falls back to a default 30-day window when params are blank" do
result = described_class.build(start_date: nil, end_date: nil)
expect(result[:kpis]).to be_an(Array)
expect(result[:kpis]).not_to be_empty
end
it "falls back to defaults when params are unparseable" do
result = described_class.build(start_date: "garbage", end_date: "also-garbage")
expect(result[:kpis]).to be_an(Array)
expect(result[:kpis]).not_to be_empty
end
it "ignores unicode garbage in date params" do
result = described_class.build(start_date: "字字字", end_date: "字字字")
expect(result[:kpis]).to be_an(Array)
expect(result[:kpis]).not_to be_empty
end
it "does not pass current_user to Report.find so admins share one cache entry" do
received_args = []
original_find = Report.method(:find)
Report.define_singleton_method(:find) do |type, opts = nil|
received_args << (opts || {})
original_find.call(type, opts)
end
described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(received_args).not_to be_empty
received_args.each { |args| expect(args).not_to have_key(:current_user) }
ensure
Report.define_singleton_method(:find, &original_find)
end
it "parses dates in Time.zone, not UTC" do
Time.use_zone("America/Los_Angeles") do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-02")
signups = result[:kpis].find { |k| k[:type] == :new_signups }
expect(signups[:report_query][:start_date]).to eq("2026-04-01")
end
Time.use_zone("Etc/UTC") do
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-02")
signups = result[:kpis].find { |k| k[:type] == :new_signups }
expect(signups[:report_query][:start_date]).to eq("2026-04-01")
end
end
it "accepts ISO8601 strings with time and drops the time-of-day" do
result =
described_class.build(start_date: "2026-04-01T18:00:00Z", end_date: "2026-04-28T23:59:59Z")
signups = result[:kpis].find { |k| k[:type] == :new_signups }
expect(signups[:report_query]).to eq(start_date: "2026-04-01", end_date: "2026-04-28")
end
describe "per-report caching" do
it "reuses Report.find_cached on subsequent calls with the same date range" do
described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
# second call should hit the report-level cache for every core KPI
Report.expects(:find).never
result = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(result[:kpis].map { |k| k[:type] }).to include(:new_signups, :new_contributors)
end
it "recomputes when the date range changes" do
described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
# different range — cache miss, so Report.find must be called at least once
Report
.expects(:find)
.at_least_once
.returns(stub(type: "signups", error: nil, data: [], prev_period: 0))
Report.stubs(:cache)
described_class.build(start_date: "2026-03-01", end_date: "2026-03-31")
end
it "re-evaluates plugin enabled procs on every build (no outer cache)" do
@kpi_enabled = true
DiscoursePluginRegistry.stubs(:admin_dashboard_highlight_kpis).returns(
[{ type: :toggleable_kpi, report: "signups", enabled: -> { @kpi_enabled } }],
)
first = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(first[:kpis].map { |k| k[:type] }).to include(:toggleable_kpi)
@kpi_enabled = false
second = described_class.build(start_date: "2026-04-01", end_date: "2026-04-28")
expect(second[:kpis].map { |k| k[:type] }).not_to include(:toggleable_kpi)
end
end
end
end