0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/spec/services/admin_dashboard_search_spec.rb
Alan Guo Xiang Tan a75083f14a
FIX: Exclude anonymous searches from the dashboard search section (#40966)
The "Search" section of the admin dashboard (the new dashboard behind
`dashboard_improvements`) counted every row in `search_logs`, including
anonymous traffic that can be dominated by crawlers. All four metrics
(Total searches, No-result rate, Trending searches, Content gaps) and
the per-term drill-down included that traffic.

This PR restricts the section to logged-in members, whose searches are
usually the higher-signal view of what members look for, surfaces a
matching "Logged in only" filter on the search log pages, and says so in
the tooltips.
2026-06-17 14:32:04 +08:00

492 lines
14 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe AdminDashboardSearch do
fab!(:user)
before { freeze_time(Time.zone.local(2026, 5, 14, 12, 0, 0)) }
describe ".build" do
it "returns KPIs, trending terms, and content gaps for the selected dates, counting only logged-in members" do
Fabricate.times(
3,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-05-02 10:00",
)
Fabricate.times(3, :search_log, term: "ruby", user: user, created_at: "2026-05-02 11:00")
Fabricate(
:clicked_search_log,
term: "markdown tables",
user: user,
created_at: "2026-05-03 10:00",
)
Fabricate.times(
4,
:search_log,
term: "markdown tables",
user: user,
search_type: SearchLog.search_types[:full_page],
created_at: "2026-05-03 11:00",
)
Fabricate.times(4, :search_log, term: "discobot", user: user, created_at: "2026-05-04 10:00")
Fabricate.times(
2,
:clicked_search_log,
term: "zeta",
user: user,
created_at: "2026-05-04 11:00",
)
Fabricate.times(2, :search_log, term: "zeta", user: user, created_at: "2026-05-04 12:00")
Fabricate.times(
5,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-04-26 10:00",
)
Fabricate.times(5, :search_log, term: "ghost", user: user, created_at: "2026-04-26 11:00")
# Anonymous searches (likely crawlers) must be excluded from every metric and from the
# prior-window deltas. If counted, "crawler-bait" would top trending, inflate the
# no-result rate, and surface as a content gap; the anonymous "ruby" rows would change
# its count and the percent change.
Fabricate.times(30, :search_log, term: "crawler-bait", created_at: "2026-05-05 10:00")
Fabricate.times(10, :search_log, term: "ruby", created_at: "2026-05-02 09:00")
Fabricate.times(20, :search_log, term: "crawler-bait", created_at: "2026-04-26 09:00")
expect(described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")).to eq(
logging_enabled: true,
headline_state: "content_gaps",
kpis: {
total_searches: {
value: 19,
percent_change: 90,
},
no_result_rate: {
value: 21,
point_change: -29,
exceeds_threshold: true,
},
},
trending: [
{ term: "ruby", searches: 6 },
{ term: "markdown tables", searches: 5 },
{ term: "zeta", searches: 4 },
{ term: "discobot", searches: 4 },
],
trending_period: "weekly",
content_gaps: [
{ term: "markdown tables", searches: 5, status: "poor_match" },
{ term: "discobot", searches: 4, status: "no_match" },
],
)
end
it "buckets terms by exact CTR boundaries" do
Fabricate(:clicked_search_log, term: "tiny-ctr", user: user, created_at: "2026-05-02 10:00")
Fabricate.times(
100,
:search_log,
term: "tiny-ctr",
user: user,
created_at: "2026-05-02 11:00",
)
Fabricate.times(
5,
:clicked_search_log,
term: "just-over",
user: user,
created_at: "2026-05-03 10:00",
)
Fabricate.times(
19,
:search_log,
term: "just-over",
user: user,
created_at: "2026-05-03 11:00",
)
Fabricate(
:clicked_search_log,
term: "exact-twenty",
user: user,
created_at: "2026-05-04 10:00",
)
Fabricate.times(
4,
:search_log,
term: "exact-twenty",
user: user,
created_at: "2026-05-04 11:00",
)
Fabricate.times(
3,
:search_log,
term: "zero-clicks",
user: user,
created_at: "2026-05-05 10:00",
)
Fabricate.times(
3,
:search_log,
term: "alpha-zero",
user: user,
created_at: "2026-05-05 11:00",
)
expect(described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")).to eq(
logging_enabled: true,
headline_state: "healthy",
kpis: {
total_searches: {
value: 136,
},
no_result_rate: {
value: 4,
exceeds_threshold: false,
},
},
trending: [
{ term: "tiny-ctr", searches: 101 },
{ term: "just-over", searches: 24 },
{ term: "exact-twenty", searches: 5 },
{ term: "alpha-zero", searches: 3 },
{ term: "zero-clicks", searches: 3 },
],
trending_period: "weekly",
content_gaps: [
{ term: "tiny-ctr", searches: 101, status: "poor_match" },
{ term: "exact-twenty", searches: 5, status: "poor_match" },
{ term: "alpha-zero", searches: 3, status: "no_match" },
{ term: "zero-clicks", searches: 3, status: "no_match" },
],
)
end
it "caps trending and content gaps at the top 10 terms" do
(1..11).each do |index|
Fabricate(
:search_log,
term: format("gap-%02d", index),
user: user,
created_at: "2026-05-02 10:00",
)
end
expect(described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")).to eq(
logging_enabled: true,
headline_state: "content_gaps",
kpis: {
total_searches: {
value: 11,
},
no_result_rate: {
value: 100,
exceeds_threshold: true,
},
},
trending: (1..10).map { |index| { term: format("gap-%02d", index), searches: 1 } },
trending_period: "weekly",
content_gaps:
(1..10).map do |index|
{ term: format("gap-%02d", index), searches: 1, status: "no_match" }
end,
)
end
it "picks the headline state from the rate and volume signals" do
Fabricate(:search_log, term: "ghost", user: user, created_at: "2026-05-02 10:00")
Fabricate.times(
11,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-05-02 11:00",
)
Fabricate.times(
12,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-04-26 10:00",
)
Fabricate.times(
4,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-04-02 10:00",
)
Fabricate.times(
10,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-03-27 10:00",
)
Fabricate.times(
12,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-03-02 10:00",
)
Fabricate.times(
10,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-02-24 10:00",
)
expect(
described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")[:headline_state],
).to eq("rate_climbing")
expect(
described_class.build(start_date: "2026-04-01", end_date: "2026-04-07")[:headline_state],
).to eq("shrinking")
expect(
described_class.build(start_date: "2026-03-01", end_date: "2026-03-07")[:headline_state],
).to eq("healthy")
end
it "resolves overlapping headline states by priority" do
Fabricate.times(3, :search_log, term: "ghost", user: user, created_at: "2026-02-02 10:00")
Fabricate.times(
7,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-02-02 11:00",
)
Fabricate.times(
10,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-01-27 10:00",
)
Fabricate(:search_log, term: "ghost", user: user, created_at: "2026-01-02 10:00")
Fabricate.times(
9,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-01-02 11:00",
)
Fabricate.times(
20,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2025-12-27 10:00",
)
expect(
described_class.build(start_date: "2026-02-01", end_date: "2026-02-07")[:headline_state],
).to eq("content_gaps")
expect(
described_class.build(start_date: "2026-01-01", end_date: "2026-01-07")[:headline_state],
).to eq("rate_climbing")
end
it "omits deltas when the prior window has no searches" do
Fabricate.times(
2,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-05-02 10:00",
)
expect(described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")[:kpis]).to eq(
total_searches: {
value: 2,
},
no_result_rate: {
value: 0,
exceeds_threshold: false,
},
)
end
it "omits deltas and rates when the current window has no searches" do
Fabricate.times(2, :search_log, term: "ruby", user: user, created_at: "2026-04-26 10:00")
expect(described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")).to eq(
logging_enabled: true,
headline_state: "no_signal",
kpis: {
total_searches: {
value: 0,
},
no_result_rate: {
value: nil,
exceeds_threshold: false,
},
},
trending: [],
trending_period: "weekly",
content_gaps: [],
)
end
it "omits deltas when nothing changed between the windows" do
Fabricate.times(
5,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-05-02 10:00",
)
Fabricate.times(
5,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-04-26 10:00",
)
expect(described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")[:kpis]).to eq(
total_searches: {
value: 5,
},
no_result_rate: {
value: 0,
exceeds_threshold: false,
},
)
end
it "reports the rate delta in percentage points, not relative change" do
Fabricate(:search_log, term: "ghost", user: user, created_at: "2026-05-02 10:00")
Fabricate.times(
3,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-05-02 11:00",
)
Fabricate(:search_log, term: "ghost", user: user, created_at: "2026-04-26 10:00")
Fabricate.times(
9,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-04-26 11:00",
)
expect(described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")[:kpis]).to eq(
total_searches: {
value: 4,
percent_change: -60,
},
no_result_rate: {
value: 25,
point_change: 15,
exceeds_threshold: true,
},
)
end
it "treats a rate of exactly 10% as within the threshold" do
Fabricate(:search_log, term: "ghost", user: user, created_at: "2026-05-02 10:00")
Fabricate.times(
9,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-05-02 11:00",
)
expect(
described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")[:kpis][
:no_result_rate
],
).to eq(value: 10, exceeds_threshold: false)
end
it "flags a rate just above 10% even when the display rounds to 10%" do
Fabricate.times(5, :search_log, term: "ghost", user: user, created_at: "2026-05-02 10:00")
Fabricate.times(
44,
:clicked_search_log,
term: "ruby",
user: user,
created_at: "2026-05-02 11:00",
)
expect(
described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")[:kpis][
:no_result_rate
],
).to eq(value: 10, exceeds_threshold: true)
end
it "maps the window length to the per-term report period" do
{
%w[2026-05-08 2026-05-14] => "weekly",
%w[2026-05-07 2026-05-14] => "monthly",
%w[2026-04-14 2026-05-14] => "monthly",
%w[2026-04-13 2026-05-14] => "quarterly",
%w[2026-02-12 2026-05-14] => "quarterly",
%w[2026-02-11 2026-05-14] => "yearly",
%w[2025-05-14 2026-05-14] => "yearly",
%w[2025-05-13 2026-05-14] => "all",
}.each do |(start_date, end_date), period|
expect(
described_class.build(start_date: start_date, end_date: end_date)[:trending_period],
).to eq(period)
end
end
it "falls back to the default 30-day window for missing, malformed, or inverted dates" do
Fabricate(:search_log, term: "inside-window", user: user, created_at: "2026-04-16 10:00")
Fabricate(:search_log, term: "outside-window", user: user, created_at: "2026-04-13 10:00")
[
described_class.build(start_date: nil, end_date: nil),
described_class.build(start_date: "not-a-date", end_date: "also-not-a-date"),
described_class.build(start_date: "2026-05-10", end_date: "2026-05-01"),
].each do |response|
expect(response[:kpis][:total_searches][:value]).to eq(1)
expect(response[:trending]).to eq([{ term: "inside-window", searches: 1 }])
end
end
it "includes searches at the window start boundary in every list" do
Fabricate(:search_log, term: "boundary", user: user, created_at: "2026-05-01 00:00:00")
response = described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")
expect(response[:kpis][:total_searches][:value]).to eq(1)
expect(response[:trending]).to eq([{ term: "boundary", searches: 1 }])
expect(response[:content_gaps]).to eq([{ term: "boundary", searches: 1, status: "no_match" }])
end
it "returns only the logging flag when search logging is disabled" do
SiteSetting.log_search_queries = false
Fabricate(:search_log, term: "ruby", user: user, created_at: "2026-05-02 10:00")
expect(described_class.build(start_date: "2026-05-01", end_date: "2026-05-07")).to eq(
logging_enabled: false,
)
end
end
end