mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
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.
321 lines
10 KiB
Ruby
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
|