0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/spec/services/admin_dashboard_highlights_spec.rb
Alan Guo Xiang Tan 395077a745
PERF: Use daily rollups for DAU/MAU reports (#41958)
DAU/MAU reports previously calculated every requested date from the full
user visit history, so dashboard requests became slower as that history
grew.

Persist the daily DAU and trailing-30-day MAU values and have the shared
report read only those rollups. The scheduled job builds all existing
history on its first successful run, then refreshes yesterday and today
every three hours. Replacements are atomic, so a failed calculation
leaves the previous report data intact.
2026-07-24 09:52:40 +08:00

220 lines
8.7 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_daily_rollup, date: Date.new(2026, 4, 10))
Fabricate(:user_visit_daily_rollup, date: Date.new(2026, 4, 15), mau: 2)
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
it "averages a registered KPI backed by an average report instead of summing it" do
DiscoursePluginRegistry.stubs(:admin_dashboard_highlight_kpis).returns(
[{ type: :plugin_engaged, report: "daily_engaged_users" }],
)
engaged_user = Fabricate(:user, created_at: Time.zone.local(2026, 1, 1))
Fabricate(
:user_action,
user: engaged_user,
action_type: UserAction::LIKE,
created_at: Time.zone.local(2026, 4, 23, 12),
)
Fabricate(
:user_action,
user: engaged_user,
action_type: UserAction::LIKE,
created_at: Time.zone.local(2026, 4, 24, 12),
)
result = described_class.build(start_date: "2026-04-22", end_date: "2026-04-28")
kpi = result[:kpis].find { |k| k[:type] == :plugin_engaged }
expect(kpi[:value]).to eq(1.0) # daily average, not the two-day sum of 2
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, average: false))
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