discourse/spec/models/problem_check_tracker_spec.rb
Ted Johansson 623f2279b0
FEATURE: Add problem checks page to admin panel and allow ignoring problem checks (#39103)
## Background

Currently you can "dismiss" problem checks on the dashboard, but if the
problem persists it will show up again on the next reload, which is
confusing.

There was previously some discussion about adding a feature to "snooze"
problem checks, but I think even with that it remains a bit too opaque.
You'd need to spelunk around in the console to try and figure out what
is going on.

## What is this change?

This PR does a couple of things:

### 1. Replace Dismiss with Ignore

Hitting ignore will prevent the problem check from creating new admin
notices until it has been unignored from the new problem checks page.

**Screenshot**

<img width="395" height="61" alt="Screenshot 2026-04-05 at 4 37 39 PM"
src="https://github.com/user-attachments/assets/4816fd04-046b-441e-9471-c160dd3f82b9"
/>

### 2. Add a new problem check page

This page provides a list of problem checks with information on whether
they are passing or failing, and when they were last run. You can also
ignore or unignore (watch) problem checks from here.

**Screenshot**

<img width="600" height="200" alt="Screenshot 2026-04-05 at 4 26 37 PM"
src="https://github.com/user-attachments/assets/d8cb2b6a-3f56-409c-97f0-312cb1545654"
/>

### 3. Remove the problem check timestamp from the dashboard

This timestamp made sense under the previous model, where all checks
were run at once and the results cached. With the new model, there's a
mix of on-demand and scheduled checks, and having a single timestamp is
misleading at best. In practice it's always going to be just the
timestamp when you last loaded the dashboard.

**Before**

<img width="240" height="100" alt="Screenshot 2026-04-05 at 4 30 21 PM"
src="https://github.com/user-attachments/assets/1846e024-0042-476e-8b5d-41b6745af75f"
/>

**After**

<img width="210" height="95" alt="Screenshot 2026-04-05 at 4 29 19 PM"
src="https://github.com/user-attachments/assets/a39c87c1-c1e3-4621-8219-e3903ba2ada4"
/>
2026-04-23 08:28:33 +08:00

380 lines
11 KiB
Ruby

# frozen_string_literal: true
RSpec.describe ProblemCheckTracker do
describe "validations" do
let(:record) { described_class.new(identifier: "twitter_login") }
it { expect(record).to validate_presence_of(:identifier) }
it { expect(record).to validate_uniqueness_of(:identifier).scoped_to(:target) }
it { expect(record).to validate_numericality_of(:blips).is_greater_than_or_equal_to(0) }
it { expect(record).to validate_presence_of(:target) }
end
describe "callbacks" do
describe "before_destroy (silence the alarm)" do
let(:tracker) do
ProblemCheckTracker.create!(identifier: "twitter_login", target: ProblemCheck::NO_TARGET)
end
before { tracker.problem! }
it "removes any associated admin notices" do
expect { tracker.destroy }.to change { AdminNotice.count }.by(-1)
end
end
end
describe ".[]" do
before { Fabricate(:problem_check_tracker, identifier: "twitter_login") }
context "when the problem check tracker already exists" do
it { expect(described_class[:twitter_login]).not_to be_new_record }
end
context "when the problem check tracker doesn't exist yet" do
it { expect(described_class[:facebook_login]).to be_previously_new_record }
end
end
describe "#check" do
before do
Fabricate(:problem_check_tracker, identifier: "twitter_login")
Fabricate(:problem_check_tracker, identifier: "missing_check")
end
context "when the tracker has a corresponding check" do
it { expect(described_class[:twitter_login].check.new).to be_a(ProblemCheck) }
end
context "when the checking logic of the tracker has been removed or renamed" do
it do
expect { described_class[:missing_check].check }.to change { described_class.count }.by(-1)
end
end
end
describe "#ready_to_run?" do
let(:problem_tracker) { described_class.new(next_run_at:) }
context "when the next run timestamp is not set" do
let(:next_run_at) { nil }
it { expect(problem_tracker).to be_ready_to_run }
end
context "when the next run timestamp is in the past" do
let(:next_run_at) { 5.minutes.ago }
it { expect(problem_tracker).to be_ready_to_run }
end
context "when the next run timestamp is in the future" do
let(:next_run_at) { 5.minutes.from_now }
it { expect(problem_tracker).not_to be_ready_to_run }
end
end
describe "#failing?" do
before { freeze_time }
let(:problem_tracker) { described_class.new(last_problem_at:, last_run_at:, last_success_at:) }
context "when the last run passed" do
let(:last_run_at) { 1.minute.ago }
let(:last_success_at) { 1.minute.ago }
let(:last_problem_at) { 11.minutes.ago }
it { expect(problem_tracker).not_to be_failing }
end
context "when the last run had a problem" do
let(:last_run_at) { 1.minute.ago }
let(:last_success_at) { 11.minutes.ago }
let(:last_problem_at) { 1.minute.ago }
it { expect(problem_tracker).to be_failing }
end
end
describe "#passing?" do
before { freeze_time }
let(:problem_tracker) { described_class.new(last_problem_at:, last_run_at:, last_success_at:) }
context "when the last run passed" do
let(:last_run_at) { 1.minute.ago }
let(:last_success_at) { 1.minute.ago }
let(:last_problem_at) { 11.minutes.ago }
it { expect(problem_tracker).to be_passing }
end
context "when the last run had a problem" do
let(:last_run_at) { 1.minute.ago }
let(:last_success_at) { 11.minutes.ago }
let(:last_problem_at) { 1.minute.ago }
it { expect(problem_tracker).not_to be_passing }
end
end
describe "#ignored?" do
let(:problem_tracker) { described_class.new(ignored_at:) }
context "when the ignored timestamp is set" do
let(:ignored_at) { 1.day.ago }
it { expect(problem_tracker).to be_ignored }
end
context "when the ignored timestamp is not set" do
let(:ignored_at) { nil }
it { expect(problem_tracker).not_to be_ignored }
end
end
describe "#watched?" do
let(:problem_tracker) { described_class.new(ignored_at:) }
context "when the ignored timestamp is set" do
let(:ignored_at) { 1.day.ago }
it { expect(problem_tracker).not_to be_watched }
end
context "when the ignored timestamp is not set" do
let(:ignored_at) { nil }
it { expect(problem_tracker).to be_watched }
end
end
describe "#ignore!" do
let(:problem_tracker) { Fabricate(:problem_check_tracker, ignored_at:) }
context "when not currently ignored" do
let(:ignored_at) { nil }
it "sets the ignore timestamp" do
freeze_time
expect { problem_tracker.ignore! }.to change { problem_tracker.ignored_at }.from(nil).to(
be_within_one_second_of Time.current
)
end
end
context "when already ignored" do
let(:ignored_at) { 1.day.ago }
it "does not touch the ignore timestamp" do
expect { problem_tracker.ignore! }.not_to change { problem_tracker.ignored_at }
end
end
end
describe "#watch!" do
let(:problem_tracker) { Fabricate(:problem_check_tracker, ignored_at:) }
context "when not currently ignored" do
let(:ignored_at) { nil }
it "does not touch the ignore timestamp" do
expect { problem_tracker.watch! }.not_to change { problem_tracker.ignored_at }
end
end
context "when currently ignored" do
let(:ignored_at) { 1.day.ago }
it "clears the ignore timestamp" do
expect { problem_tracker.watch! }.to change { problem_tracker.ignored_at }.from(
be_within_one_second_of ignored_at
).to(nil)
end
end
end
describe "#problem!" do
let(:problem_tracker) do
Fabricate(
:problem_check_tracker,
identifier: "twitter_login",
target: "foo",
ignored_at:,
**original_attributes,
)
end
let(:original_attributes) do
{
blips:,
last_problem_at: 1.week.ago,
last_success_at: 24.hours.ago,
last_run_at: 24.hours.ago,
next_run_at: nil,
}
end
let(:blips) { 0 }
let(:updated_attributes) { { blips: 1 } }
let(:ignored_at) { nil }
it do
freeze_time
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.to change {
problem_tracker.attributes
}.to(hash_including(updated_attributes))
end
context "when the maximum number of blips have been surpassed" do
let(:blips) { 1 }
context "when the check isn't being ignored" do
let(:ignored_at) { nil }
it "sounds the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.to change {
AdminNotice.problem.count
}.by(1)
end
end
context "when the check is being ignored" do
let(:ignored_at) { 1.day.ago }
it "does not sound the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.not_to change {
AdminNotice.problem.count
}
end
end
end
context "when the details of the problem change but the problem remains" do
let(:blips) { 1 }
it "updates the notice" do
original_details = {
themes_list:
"<ul><li><a href=\"/admin/customize/themes/13\">discourse-blank-theme</a></li> <li><a href=\"/admin/customize/themes/31\">Simple Theme</a></li></ul>",
base_path: "",
}
expect do problem_tracker.problem!(details: original_details) end.to change {
AdminNotice.problem.count
}.by(1)
admin_notice = AdminNotice.problem.find_by(identifier: "twitter_login")
expect(
admin_notice.details.merge(target: problem_tracker.target).with_indifferent_access,
).to eq(original_details.merge(target: problem_tracker.target).with_indifferent_access)
new_details = {
themes_list: "<ul><li><a href=\"/admin/customize/themes/31\">Simple Theme</a></li></ul>",
base_path: "",
}
expect do problem_tracker.problem!(details: new_details) end.not_to change {
AdminNotice.problem.count
}
admin_notice.reload
expect(
admin_notice.details.merge(target: problem_tracker.target).with_indifferent_access,
).to eq(new_details.merge(target: problem_tracker.target).with_indifferent_access)
end
end
context "when there's an alarm sounding for multi-target trackers" do
let(:blips) { 1 }
before do
Fabricate(
:admin_notice,
subject: "problem",
identifier: "twitter_login",
details: {
target: target,
},
)
end
context "when the alarm is for a different target" do
let(:target) { "bar" }
it "sounds the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.to change {
AdminNotice.problem.count
}.by(1)
end
end
context "when the alarm is for a the same target" do
let(:target) { "foo" }
it "does not duplicate the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.not_to change {
AdminNotice.problem.count
}
end
end
end
context "when there are still blips to go" do
let(:blips) { 0 }
before { ProblemCheck::TwitterLogin.stubs(:max_blips).returns(1) }
it "does not sound the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.not_to change {
AdminNotice.problem.count
}
end
end
end
describe "#no_problem!" do
let(:next_run_at) { 24.hours.from_now.round(6) }
let(:problem_tracker) do
Fabricate(:problem_check_tracker, identifier: "twitter_login", **original_attributes)
end
let(:original_attributes) do
{
blips: 0,
last_problem_at: 1.week.ago,
last_success_at: Time.current,
last_run_at: 24.hours.ago,
next_run_at: nil,
}
end
let(:updated_attributes) { { blips: 0, next_run_at: } }
it do
freeze_time
expect { problem_tracker.no_problem!(next_run_at:) }.to change {
problem_tracker.attributes
}.to(hash_including(updated_attributes))
end
context "when there's an alarm sounding" do
before { problem_tracker.problem! }
it "silences the alarm" do
expect { problem_tracker.no_problem!(next_run_at: 24.hours.from_now) }.to change {
AdminNotice.problem.count
}.by(-1)
end
end
end
end