Files
discourse/plugins/voice/spec/services/voice/badge_granter_hooks_spec.rb
T
Rafael dos Santos Silva 07b05ee8cd FEATURE: Enable voice badges by default and harden them first (#43190)
The team wants the voice badge set on by default. Reviewing the fixtures
and grant hooks first turned up a handful of issues that only bite once
real sites accumulate history, so this PR fixes them and flips the
default in one go.

### Fixes

- **Scheduled badges were auto-revocable.** Core's `Badge.auto_revoke`
defaults to `true` and the fixtures never overrode it. Sessions and
co-presence rows are purged after `voice_session_retention_days` (400),
and a deleted user's co-presence rows are dropped, so the daily backfill
would have silently revoked airtime, bonding, loyalty, exploration and
hosting badges (and any titles set from them) as data aged out. All 19
query badges now set `auto_revoke = false`, same as core's cumulative
badges.
- **Crowd Puller / Master of Ceremonies were farmable.** The hosting
query counted `COUNT(s.id)` across a creator's rooms, including the
creator's own joins. At the 30/min join rate limit a Gold title took ~17
minutes. It now counts `DISTINCT` visitors excluding the creator.
- **Packed House was unreachable for most rooms.** It read
`room.max_participants`, which is `nil` whenever the room is capped by
the site-wide setting. It now uses `effective_max_participants`,
matching admission.
- **Bulk toggles skipped badge maintenance.**
`enable_all!`/`disable_all!` used `update_all`, bypassing the `Badge`
callbacks that refresh featured ranks and distinct badge counts, leaving
stale profile counts. They now run that once after the flip, and
enabling also enqueues a backfill for the query badges so they show up
right away instead of after the next daily job.
- **Analytics dependency made explicit.** Every badge is computed from
`voice_sessions`; with analytics off nothing can be earned. The hooks
now check `voice_analytics_enabled` and the setting description
references it.

### Default flip

`voice_badges_enabled` now defaults to `true` and the fixtures seed
badges as `default_enabled`. Neither reaches existing sites on its own:
the settings-changed hook doesn't fire for a default change, and
`default_enabled` only applies to new records. A post-migration enables
the Voice-grouping badges on sites that have no explicit
`voice_badges_enabled = false` row.

### Tests

- New `badge_backfill_spec.rb` runs `BadgeGranter.backfill` for every
SQL family at its threshold boundary, including the retention-purge case
and the hosting self-join exclusion.
- Migration spec covers untouched, explicitly-disabled, and non-Voice
badges.
- Hooks spec updated for the site-cap fallback, analytics gate,
default-enabled seeding, and backfill enqueueing.

### Not changed (design notes from the review)

- Weekend Warrior and Loyalty use server UTC for day boundaries while
Night Owl / Early Bird use the user's timezone. Left as is; user
timezones in SQL would need per-row `AT TIME ZONE` with invalid-name
handling.
- Icebreaker largely overlaps Mic Check since co-presence rows lag five
minutes. Bronze, harmless.
- Marathoner relies on `voice_afk_disconnect_threshold_minutes` (30) to
keep idle tabs from earning a Gold title.
2026-09-04 14:00:17 -03:00

321 lines
10 KiB
Ruby

# frozen_string_literal: true
require "rails_helper"
RSpec.describe Voice::BadgeGranterHooks do
fab!(:user) { Fabricate(:user, trust_level: TrustLevel[2]) }
fab!(:room, :voice_room) { Fabricate(:voice_room, public: true, max_participants: 3) }
before do
SiteSetting.voice_enabled = true
SiteSetting.voice_badges_enabled = true
SiteSetting.voice_analytics_enabled = true
SeedFu.seed(Rails.root.join("plugins/voice/db/fixtures"))
described_class.enable_all!
end
describe ".on_leave" do
def build_session(joined_at:, left_at:)
Voice::Session.create!(user: user, room: room, joined_at: joined_at, left_at: left_at)
end
describe "Mic Check" do
fab!(:other, :user)
it "grants after 30 seconds spent with someone else in the room" do
Fabricate(:voice_session, user: other, room: room, joined_at: 2.minutes.ago)
session = build_session(joined_at: 1.minute.ago, left_at: Time.current)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).to include("Mic Check")
end
it "does not grant when the other person was present for under 30 seconds" do
Fabricate(:voice_session, user: other, room: room, joined_at: 20.seconds.ago)
session = build_session(joined_at: 1.minute.ago, left_at: Time.current)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).not_to include("Mic Check")
end
it "does not grant when alone in the room" do
session = build_session(joined_at: 1.minute.ago, left_at: Time.current)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).not_to include("Mic Check")
end
end
describe "Night Owl" do
it "grants when session started between midnight and 5 AM in user timezone" do
user.user_option.update!(timezone: "America/New_York")
# 2 AM in New York
joined = Time.zone.parse("2026-03-06 07:00:00 UTC")
left = joined + 5.minutes
session = build_session(joined_at: joined, left_at: left)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).to include("Night Owl")
end
it "does not grant during daytime hours" do
user.user_option.update!(timezone: "UTC")
joined = Time.zone.parse("2026-03-06 14:00:00 UTC")
left = joined + 5.minutes
session = build_session(joined_at: joined, left_at: left)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).not_to include("Night Owl")
end
end
describe "Early Bird" do
it "grants when session started between 5 AM and 9 AM in user timezone" do
user.user_option.update!(timezone: "UTC")
joined = Time.zone.parse("2026-03-06 06:00:00 UTC")
left = joined + 5.minutes
session = build_session(joined_at: joined, left_at: left)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).to include("Early Bird")
end
end
describe "Marathoner" do
fab!(:companion, :user)
it "grants when 4+ hours of the session were spent with someone else" do
Fabricate(:voice_session, user: companion, room: room, joined_at: 4.5.hours.ago)
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).to include("Marathoner")
end
it "does not grant for 4+ hours spent alone" do
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).not_to include("Marathoner")
end
it "does not grant when company stayed under 4 hours" do
Fabricate(:voice_session, user: companion, room: room, joined_at: 3.hours.ago)
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).not_to include("Marathoner")
end
end
it "does nothing when badges are disabled" do
SiteSetting.voice_badges_enabled = false
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
described_class.on_leave(user, session)
expect(user.badges).to be_empty
end
it "does nothing when session has no left_at" do
session = Voice::Session.create!(user: user, room: room, joined_at: 5.hours.ago)
described_class.on_leave(user, session)
expect(user.badges).to be_empty
end
end
describe ".on_join" do
describe "Packed House" do
it "grants when room reaches max capacity" do
other1 = Fabricate(:user)
other2 = Fabricate(:user)
participants = User.where(id: [user.id, other1.id, other2.id])
described_class.on_join(user, room, participants)
expect(user.badges.pluck(:name)).to include("Packed House")
end
it "falls back to the site-wide cap when the room has no max_participants" do
room.update!(max_participants: nil)
SiteSetting.voice_max_room_participants = 2
other = Fabricate(:user)
described_class.on_join(user, room, User.where(id: [user.id]))
expect(user.badges.pluck(:name)).not_to include("Packed House")
described_class.on_join(user, room, User.where(id: [user.id, other.id]))
expect(user.badges.pluck(:name)).to include("Packed House")
end
end
describe "Icebreaker" do
fab!(:stranger, :user)
it "grants when user has no co-presence history with anyone in the room" do
Voice::ParticipantTracker.add(room.id, stranger.id)
participants = User.where(id: [user.id, stranger.id])
described_class.on_join(user, room, participants)
expect(user.badges.pluck(:name)).to include("Icebreaker")
end
it "does not grant when user has co-presence history" do
ids = [user.id, stranger.id].sort
Voice::CoPresence.create!(
user_id_1: ids.first,
user_id_2: ids.last,
date: Date.current,
total_seconds: 60,
session_count: 1,
)
participants = User.where(id: [user.id, stranger.id])
described_class.on_join(user, room, participants)
expect(user.badges.pluck(:name)).not_to include("Icebreaker")
end
it "does not grant when user is alone in the room" do
participants = User.where(id: [user.id])
described_class.on_join(user, room, participants)
expect(user.badges.pluck(:name)).not_to include("Icebreaker")
end
end
end
describe ".on_room_create" do
it "grants Host badge" do
described_class.on_room_create(user)
expect(user.badges.pluck(:name)).to include("Host")
end
it "does nothing when badges are disabled" do
SiteSetting.voice_badges_enabled = false
described_class.on_room_create(user)
expect(user.badges).to be_empty
end
it "does nothing when analytics are disabled" do
SiteSetting.voice_analytics_enabled = false
described_class.on_room_create(user)
expect(user.badges).to be_empty
end
end
describe ".on_invite_redeemed" do
fab!(:invitee) { Fabricate(:user, trust_level: TrustLevel[2]) }
def redeemed_invite
Voice::Invite.create!(
room_id: room.id,
user_id: invitee.id,
invited_by_id: user.id,
redeemed_at: Time.current,
)
end
it "grants Plus One to the inviter" do
described_class.on_invite_redeemed(redeemed_invite)
expect(user.badges.pluck(:name)).to include("Plus One")
expect(invitee.badges).to be_empty
end
it "does nothing when badges are disabled" do
SiteSetting.voice_badges_enabled = false
described_class.on_invite_redeemed(redeemed_invite)
expect(user.badges).to be_empty
end
end
describe "fixture seeding" do
def voice_badges
Badge.joins(:badge_grouping).where(badge_groupings: { name: "Voice" })
end
it "creates all badges enabled" do
expect(voice_badges.count).to eq(27)
expect(voice_badges.where(enabled: false).count).to eq(0)
end
it "never auto-revokes scheduled badges" do
expect(voice_badges.where.not(query: nil)).to all(have_attributes(auto_revoke: false))
end
it "creates the Voice badge grouping" do
expect(BadgeGrouping.exists?(name: "Voice")).to eq(true)
end
it "is idempotent" do
expect { SeedFu.seed(Rails.root.join("plugins/voice/db/fixtures")) }.not_to change {
Badge.count
}
end
it "sets SQL queries on scheduled badges" do
expect(Badge.find_by(name: "Rookie").query).to include("voice_sessions")
expect(Badge.find_by(name: "Social Butterfly").query).to include("voice_co_presences")
end
it "does not set queries on instant badges" do
%w[Mic\ Check Host Icebreaker Packed\ House Night\ Owl Early\ Bird Marathoner].each do |name|
expect(Badge.find_by(name: name).query).to be_nil, "Expected #{name} to have no query"
end
end
it "allows gold badges to be used as title" do
gold_badges = voice_badges.where(badge_type_id: BadgeType::Gold)
expect(gold_badges).to all(have_attributes(allow_title: true))
end
end
describe ".enable_all!" do
before { described_class.disable_all! }
it "enables all Voice badges and schedules a backfill for the scheduled ones" do
described_class.enable_all!
voice_badges = Badge.joins(:badge_grouping).where(badge_groupings: { name: "Voice" })
expect(voice_badges.where(enabled: false).count).to eq(0)
expect_job_enqueued(
job: :backfill_badge,
args: {
badge_id: Badge.find_by(name: "Rookie").id,
},
)
expect_not_enqueued_with(
job: :backfill_badge,
args: {
badge_id: Badge.find_by(name: "Mic Check").id,
},
)
end
end
describe ".disable_all!" do
it "disables all Voice badges" do
described_class.disable_all!
voice_badges = Badge.joins(:badge_grouping).where(badge_groupings: { name: "Voice" })
expect(voice_badges.where(enabled: true).count).to eq(0)
end
end
end