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.
This commit is contained in:
Rafael dos Santos Silva
2026-09-04 14:00:17 -03:00
committed by GitHub
parent 5973f83419
commit 07b05ee8cd
14 changed files with 547 additions and 74 deletions
+1
View File
@@ -24410,6 +24410,7 @@ SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20260904065041'),
('20260904063128'),
('20260903195501'),
('20260903065141'),
('20260902150024'),
('20260901020329'),
@@ -371,7 +371,7 @@ module Voice
end
Voice::UserStatusManager.clear_voice_status(current_user)
Voice::RoomBroadcaster.publish_participants(@room)
Voice::BadgeGranterHooks.on_leave(current_user, session, room: @room)
Voice::BadgeGranterHooks.on_leave(current_user, session)
head :no_content
end
@@ -505,7 +505,7 @@ module Voice
kicked_user = User.find_by(id: user_id)
Voice::UserStatusManager.clear_voice_status(kicked_user) if kicked_user
Voice::BadgeGranterHooks.on_leave(kicked_user, session, room: @room) if kicked_user
Voice::BadgeGranterHooks.on_leave(kicked_user, session) if kicked_user
Voice::RoomBroadcaster.publish_kick(@room, user_id)
Voice::RoomBroadcaster.publish_participants(@room)
@@ -25,8 +25,7 @@ module Jobs
::Voice::ParticipantTracker.remove(session.room_id, session.user_id)
user = User.find_by(id: session.user_id)
room = ::Voice::Room.find_by(id: session.room_id)
::Voice::BadgeGranterHooks.on_leave(user, session, room: room) if user && room
::Voice::BadgeGranterHooks.on_leave(user, session) if user
end
end
end
+29
View File
@@ -16,6 +16,35 @@ module Voice
update!(left_at: at)
end
# Seconds of this session during which someone else was in the room.
# Concurrent companions are merged so a crowded hour counts once.
def accompanied_seconds
finish = left_at || Time.current
intervals =
self
.class
.where(room_id: room_id)
.where.not(user_id: user_id)
.where("joined_at < ?", finish)
.where("left_at IS NULL OR left_at > ?", joined_at)
.order(:joined_at)
.pluck(:joined_at, :left_at)
.map { |start, stop| [[start, joined_at].max, [stop || Time.current, finish].min] }
total = 0
merged_start = merged_end = nil
intervals.each do |start, stop|
if merged_end && start <= merged_end
merged_end = [merged_end, stop].max
else
total += merged_end - merged_start if merged_end
merged_start, merged_end = start, stop
end
end
total += merged_end - merged_start if merged_end
total.to_i
end
# A session's recorded end can be far later than the user's actual leave —
# the orphan sweep stamps left_at when it finally runs, and until then an
# abandoned session is open-ended — so any single shared interval is
@@ -2,11 +2,11 @@
module Voice
class BadgeGranterHooks
def self.on_leave(user, session, room:)
def self.on_leave(user, session)
return unless badges_enabled?
return if session&.left_at.blank?
grant("Mic Check", user) if mic_check?(session, room)
grant("Mic Check", user) if mic_check?(session)
grant("Night Owl", user) if night_owl?(user, session)
grant("Early Bird", user) if early_bird?(user, session)
grant("Marathoner", user) if marathoner?(session)
@@ -33,14 +33,22 @@ module Voice
BADGE_GROUP_NAME = "Voice"
# The site setting is a master switch over the whole grouping. Badges are
# flipped in bulk, so the per-badge save callbacks that keep user badge
# counts consistent run once here instead.
def self.enable_all!
grouping = BadgeGrouping.find_by(name: BADGE_GROUP_NAME)
Badge.where(badge_grouping_id: grouping.id).update_all(enabled: true) if grouping
badges = voice_badges
badges.update_all(enabled: true)
sync_user_badges!
badges
.where.not(query: nil)
.pluck(:id)
.each { |badge_id| Jobs.enqueue(:backfill_badge, badge_id: badge_id) }
end
def self.disable_all!
grouping = BadgeGrouping.find_by(name: BADGE_GROUP_NAME)
Badge.where(badge_grouping_id: grouping.id).update_all(enabled: false) if grouping
voice_badges.update_all(enabled: false)
sync_user_badges!
end
class << self
@@ -51,9 +59,8 @@ module Voice
BadgeGranter.grant(badge, user) if badge&.enabled?
end
def mic_check?(session, room)
duration = (session.left_at - session.joined_at).to_i
duration >= 30 && Voice::ParticipantTracker.user_ids(room.id).any?
def mic_check?(session)
session.accompanied_seconds >= 30
end
def night_owl?(user, session)
@@ -67,12 +74,11 @@ module Voice
end
def marathoner?(session)
duration = (session.left_at - session.joined_at).to_i
duration >= 4.hours.to_i
session.accompanied_seconds >= 4.hours.to_i
end
def room_full?(room, participants)
room.max_participants.present? && participants.count >= room.max_participants
participants.count >= room.effective_max_participants
end
def icebreaker?(user, participants)
@@ -90,8 +96,20 @@ module Voice
time.in_time_zone(tz).hour
end
def voice_badges
Badge.joins(:badge_grouping).where(badge_groupings: { name: BADGE_GROUP_NAME })
end
def sync_user_badges!
UserBadge.ensure_consistency!
UserStat.update_distinct_badge_count
end
# Every badge here is derived from analytics sessions, so without them
# nothing can be earned.
def badges_enabled?
SiteSetting.enable_badges && SiteSetting.voice_badges_enabled
SiteSetting.enable_badges && SiteSetting.voice_badges_enabled &&
SiteSetting.voice_analytics_enabled
end
end
end
+5 -3
View File
@@ -11,7 +11,9 @@ en:
voice_max_room_participants: "Maximum number of people who can be in a voice room at the same time. A room's own participant limit can lower this, but never raise it."
voice_auto_status_enabled: "Automatically set user status when they join a voice room (e.g. '🎙️ In Watercooler'). Requires the 'enable user status' site setting."
voice_mesh_privacy_warning_enabled: "Warn users before they join a peer-to-peer voice room that other participants may be able to see their IP address, with an option to not show the warning again on that device. Calls hosted on a LiveKit server never show the warning."
voice_badges_enabled: "Enable voice chat badges. Grants badges for voice room milestones like time spent, rooms visited, and connections made."
voice_badges_enabled: "Enable voice chat badges. Grants badges for voice room milestones like time spent, rooms visited, and connections made. Toggling this enables or disables every badge in the Voice group at once. Requires {{setting:voice_analytics_enabled}}, which records the sessions the badges are computed from."
voice_analytics_enabled: "Record who joined which voice room and when, and how long people spent in a room together. Powers the admin voice statistics, the contacts and companions suggestions, and every voice badge."
voice_session_retention_days: "Days to keep voice session and time-together history before it is deleted. Badges already earned are never taken away, but progress toward badges that accumulate over time (hours in voice, days in a room, people met) only counts history within this window."
voice_idle_threshold_minutes: "Minutes of inactivity before a voice room participant is marked as idle. Set to 0 to disable."
voice_afk_auto_mute_threshold_minutes: "Minutes of inactivity before a voice room participant is automatically muted. Set to 0 to disable."
voice_afk_disconnect_threshold_minutes: "Minutes of inactivity before a voice room participant is automatically disconnected. Set to 0 to disable."
@@ -238,5 +240,5 @@ en:
long_description: "Awarded for accumulating 5 or more hours of voice chat on weekends."
marathoner:
name: "Marathoner"
description: "Stayed in a single voice session for 4 hours."
long_description: "Awarded for staying in a single continuous voice session for 4 or more hours."
description: "Spent 4 hours in a single voice session with company."
long_description: "Awarded for a single continuous voice session with 4 or more hours spent while at least one other person was in the room."
+2 -2
View File
@@ -158,11 +158,11 @@ plugins:
default: true
client: true
voice_badges_enabled:
default: false
default: true
voice_analytics_enabled:
default: true
voice_session_retention_days:
default: 400
default: 1095
min: 7
max: 3650
voice_chat_enabled:
+32 -22
View File
@@ -62,7 +62,7 @@ Badge.seed(:name) do |b|
b.query = nil
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.system = true
end
@@ -86,9 +86,10 @@ end
GROUP BY user_id
HAVING SUM(#{duration_sql}) >= #{threshold}
SQL
b.auto_revoke = false
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.default_allow_title = type == BadgeType::Gold
b.system = true
end
@@ -106,7 +107,7 @@ Badge.seed(:name) do |b|
b.query = nil
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.system = true
end
@@ -122,9 +123,10 @@ end
b.target_posts = false
b.show_posts = false
b.query = co_presence_distinct_partners.call(count)
b.auto_revoke = false
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.default_allow_title = type == BadgeType::Gold
b.system = true
end
@@ -145,9 +147,10 @@ end
b.target_posts = false
b.show_posts = false
b.query = co_presence_with_one_partner.call(threshold)
b.auto_revoke = false
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.default_allow_title = type == BadgeType::Gold
b.system = true
end
@@ -168,14 +171,16 @@ end
b.target_posts = false
b.show_posts = false
b.query = <<~SQL
SELECT user_id, current_timestamp granted_at
FROM voice_sessions
GROUP BY user_id
HAVING COUNT(DISTINCT room_id) >= #{count}
SELECT s.user_id, current_timestamp granted_at
FROM voice_sessions s
JOIN voice_rooms r ON r.id = s.room_id AND NOT r.ephemeral
GROUP BY s.user_id
HAVING COUNT(DISTINCT s.room_id) >= #{count}
SQL
b.auto_revoke = false
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.default_allow_title = type == BadgeType::Gold
b.system = true
end
@@ -196,9 +201,10 @@ end
b.target_posts = false
b.show_posts = false
b.query = loyalty_query.call(days)
b.auto_revoke = false
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.default_allow_title = type == BadgeType::Gold
b.system = true
end
@@ -216,7 +222,7 @@ Badge.seed(:name) do |b|
b.query = nil
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.system = true
end
@@ -234,13 +240,15 @@ end
b.query = <<~SQL
SELECT r.creator_id user_id, current_timestamp granted_at
FROM voice_rooms r
JOIN voice_sessions s ON s.room_id = r.id
JOIN voice_sessions s ON s.room_id = r.id AND s.user_id <> r.creator_id
WHERE NOT r.ephemeral
GROUP BY r.creator_id
HAVING COUNT(s.id) >= #{count}
HAVING COUNT(DISTINCT s.user_id) >= #{count}
SQL
b.auto_revoke = false
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.default_allow_title = type == BadgeType::Gold
b.system = true
end
@@ -258,7 +266,7 @@ Badge.seed(:name) do |b|
b.query = nil
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.system = true
end
@@ -280,9 +288,10 @@ end
GROUP BY invited_by_id
HAVING COUNT(DISTINCT user_id) >= #{count}
SQL
b.auto_revoke = false
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.default_allow_title = type == BadgeType::Gold
b.system = true
end
@@ -300,7 +309,7 @@ Badge.seed(:name) do |b|
b.query = nil
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.system = true
end
@@ -314,7 +323,7 @@ Badge.seed(:name) do |b|
b.query = nil
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.system = true
end
@@ -328,7 +337,7 @@ Badge.seed(:name) do |b|
b.query = nil
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.system = true
end
@@ -346,9 +355,10 @@ Badge.seed(:name) do |b|
GROUP BY user_id
HAVING SUM(#{duration_sql}) >= #{5.hours.to_i}
SQL
b.auto_revoke = false
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.system = true
end
@@ -362,7 +372,7 @@ Badge.seed(:name) do |b|
b.query = nil
b.default_badge_grouping_id = voice_grouping.id
b.trigger = Badge::Trigger::None
b.default_enabled = false
b.default_enabled = true
b.default_allow_title = true
b.system = true
end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
class EnableVoiceBadgesByDefault < ActiveRecord::Migration[8.0]
# The setting default flipped to true. Sites that never touched it were
# implicitly off and their seeded badges are disabled; the setting-changed
# hook that normally flips them never fires for a default change.
def up
execute <<~SQL
UPDATE badges
SET enabled = true
FROM badge_groupings
WHERE badges.badge_grouping_id = badge_groupings.id
AND badge_groupings.name = 'Voice'
AND NOT EXISTS (
SELECT 1 FROM site_settings
WHERE name = 'voice_badges_enabled' AND value = 'f'
)
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,58 @@
# frozen_string_literal: true
RSpec.describe Jobs::Voice::PurgeOldSessions do
fab!(:user)
fab!(:partner, :user)
fab!(:room, :voice_room)
before { SiteSetting.voice_enabled = true }
def create_session(created_at)
Fabricate(:voice_session, user: user, room: room).tap do |session|
session.update_columns(created_at: created_at)
end
end
def create_co_presence(date)
first, second = [user.id, partner.id].sort
Voice::CoPresence.create!(
user_id_1: first,
user_id_2: second,
date: date,
total_seconds: 300,
session_count: 1,
)
end
it "deletes sessions and co-presence older than the retention period" do
retention = SiteSetting.voice_session_retention_days
old_session = create_session((retention + 1).days.ago)
recent_session = create_session((retention - 1).days.ago)
create_co_presence((retention + 1).days.ago.to_date)
recent_co_presence = create_co_presence((retention - 1).days.ago.to_date)
described_class.new.execute({})
expect(Voice::Session.all).to contain_exactly(recent_session)
expect(Voice::CoPresence.all).to contain_exactly(recent_co_presence)
expect(Voice::Session.exists?(old_session.id)).to eq(false)
end
it "keeps three years of history by default" do
kept = create_session(1094.days.ago)
create_session(1096.days.ago)
described_class.new.execute({})
expect(Voice::Session.all).to contain_exactly(kept)
end
it "does nothing when voice is disabled" do
SiteSetting.voice_enabled = false
old_session = create_session(10.years.ago)
described_class.new.execute({})
expect(Voice::Session.exists?(old_session.id)).to eq(true)
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
require Rails.root.join(
"plugins/voice/db/post_migrate/20260903195501_enable_voice_badges_by_default.rb",
)
RSpec.describe EnableVoiceBadgesByDefault do
before do
@original_verbose = ActiveRecord::Migration.verbose
ActiveRecord::Migration.verbose = false
SeedFu.seed(Rails.root.join("plugins/voice/db/fixtures"))
Voice::BadgeGranterHooks.disable_all!
end
after { ActiveRecord::Migration.verbose = @original_verbose }
def voice_badges
Badge.joins(:badge_grouping).where(badge_groupings: { name: "Voice" })
end
it "enables the voice badges on sites that never set the setting" do
described_class.new.up
expect(voice_badges.where(enabled: false)).to be_empty
end
it "leaves the badges alone on sites that explicitly disabled them" do
# The test provider keeps settings in memory; the migration reads the table.
DB.exec(
"INSERT INTO site_settings (name, data_type, value, created_at, updated_at) VALUES ('voice_badges_enabled', :type, 'f', NOW(), NOW())",
type: SiteSettings::TypeSupervisor.types[:bool],
)
described_class.new.up
expect(voice_badges.where(enabled: true)).to be_empty
end
it "does not touch badges outside the Voice grouping" do
Badge.find_by(name: "Editor").update!(enabled: false)
described_class.new.up
expect(Badge.find_by(name: "Editor").enabled).to eq(false)
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
RSpec.describe Voice::Session do
fab!(:user)
fab!(:room, :voice_room)
describe "#accompanied_seconds" do
let(:started_at) { Time.zone.parse("2026-09-01 10:00") }
let(:session) do
Fabricate(
:voice_session,
user: user,
room: room,
joined_at: started_at,
left_at: started_at + 4.hours,
)
end
def companion_session(from, to, in_room: room)
Fabricate(
:voice_session,
user: Fabricate(:user),
room: in_room,
joined_at: started_at + from,
left_at: to && started_at + to,
)
end
it "returns zero when nobody else was in the room" do
companion_session(0.hours, 4.hours, in_room: Fabricate(:voice_room))
expect(session.accompanied_seconds).to eq(0)
end
it "clips companions to the session and merges concurrent ones" do
companion_session(-1.hour, 1.hour)
companion_session(0.5.hours, 1.5.hours)
companion_session(3.hours, 6.hours)
expect(session.accompanied_seconds).to eq(2.5.hours.to_i)
end
it "counts a companion still in the room up to the session end" do
freeze_time(started_at + 10.hours)
companion_session(2.hours, nil)
expect(session.accompanied_seconds).to eq(2.hours.to_i)
end
end
end
@@ -0,0 +1,200 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe BadgeGranter, ".backfill" do
fab!(:user)
fab!(:room, :voice_room)
before do
SiteSetting.voice_enabled = true
SiteSetting.voice_badges_enabled = true
SeedFu.seed(Rails.root.join("plugins/voice/db/fixtures"))
Voice::BadgeGranterHooks.enable_all!
end
def backfill(badge_name)
BadgeGranter.backfill(Badge.find_by!(name: badge_name))
end
def holders_of(badge_name)
UserBadge.joins(:badge).where(badges: { name: badge_name }).pluck(:user_id)
end
def create_session(user_id:, room_id: room.id, joined_at:, duration: 10.minutes)
Voice::Session.create!(
user_id: user_id,
room_id: room_id,
joined_at: joined_at,
left_at: joined_at + duration,
)
end
# Session and co-presence rows only reference user ids, so partners need not
# be real users; the grantee does, since core joins the users table.
def create_co_presence(user_id, partner_id, total_seconds:, date: Date.current)
first, second = [user_id, partner_id].sort
Voice::CoPresence.create!(
user_id_1: first,
user_id_2: second,
date: date,
total_seconds: total_seconds,
session_count: 1,
)
end
describe "airtime" do
it "grants Rookie at one hour of total session time" do
create_session(user_id: user.id, joined_at: 3.hours.ago, duration: 30.minutes)
create_session(user_id: user.id, joined_at: 2.hours.ago, duration: 29.minutes)
backfill("Rookie")
expect(holders_of("Rookie")).to be_empty
create_session(user_id: user.id, joined_at: 1.hour.ago, duration: 1.minute)
backfill("Rookie")
expect(holders_of("Rookie")).to contain_exactly(user.id)
end
it "counts an open session up to now" do
Voice::Session.create!(user: user, room: room, joined_at: 61.minutes.ago, left_at: nil)
backfill("Rookie")
expect(holders_of("Rookie")).to contain_exactly(user.id)
end
it "keeps the badge once the sessions that earned it are purged" do
create_session(user_id: user.id, joined_at: 2.hours.ago, duration: 1.hour)
backfill("Rookie")
Voice::Session.delete_all
backfill("Rookie")
expect(holders_of("Rookie")).to contain_exactly(user.id)
end
end
describe "networker" do
it "grants Social Butterfly for ten partners with at least five minutes each" do
9.times { |index| create_co_presence(user.id, 100_000 + index, total_seconds: 300) }
create_co_presence(user.id, 200_000, total_seconds: 299)
backfill("Social Butterfly")
expect(holders_of("Social Butterfly")).to be_empty
create_co_presence(user.id, 200_000, total_seconds: 1, date: Date.yesterday)
backfill("Social Butterfly")
expect(holders_of("Social Butterfly")).to contain_exactly(user.id)
end
end
describe "bonding" do
fab!(:partner, :user)
it "grants Familiar Face to both sides after two hours together across days" do
create_co_presence(user.id, partner.id, total_seconds: 1.hour.to_i)
create_co_presence(user.id, 100_001, total_seconds: 1.hour.to_i)
backfill("Familiar Face")
expect(holders_of("Familiar Face")).to be_empty
create_co_presence(user.id, partner.id, total_seconds: 1.hour.to_i, date: Date.yesterday)
backfill("Familiar Face")
expect(holders_of("Familiar Face")).to contain_exactly(user.id, partner.id)
end
end
describe "exploration" do
it "grants Explorer for five distinct rooms, ignoring ephemeral ones" do
rooms = Array.new(4) { Fabricate(:voice_room) }
rooms.each do |visited|
2.times { create_session(user_id: user.id, room_id: visited.id, joined_at: 1.day.ago) }
end
call_room = Fabricate(:voice_ephemeral_room)
create_session(user_id: user.id, room_id: call_room.id, joined_at: 1.day.ago)
backfill("Explorer")
expect(holders_of("Explorer")).to be_empty
create_session(user_id: user.id, joined_at: 1.hour.ago)
backfill("Explorer")
expect(holders_of("Explorer")).to contain_exactly(user.id)
end
end
describe "loyalty" do
it "grants Patron for ten distinct days in the same room" do
9.times do |day|
create_session(user_id: user.id, joined_at: (day + 1).days.ago.change(hour: 12))
create_session(user_id: user.id, joined_at: (day + 1).days.ago.change(hour: 14))
end
create_session(user_id: user.id, room_id: Fabricate(:voice_room).id, joined_at: 1.hour.ago)
backfill("Patron")
expect(holders_of("Patron")).to be_empty
create_session(user_id: user.id, joined_at: 10.days.ago.change(hour: 12))
backfill("Patron")
expect(holders_of("Patron")).to contain_exactly(user.id)
end
end
describe "hosting" do
fab!(:hosted_room, :voice_room) { Fabricate(:voice_room, creator: user) }
it "grants Crowd Puller for fifty distinct visitors, ignoring repeat, self and call joins" do
49.times do |index|
create_session(user_id: 100_000 + index, room_id: hosted_room.id, joined_at: 1.day.ago)
end
call_room = Fabricate(:voice_ephemeral_room, creator: user)
create_session(user_id: 300_000, room_id: call_room.id, joined_at: 1.day.ago)
5.times { create_session(user_id: 100_000, room_id: hosted_room.id, joined_at: 1.hour.ago) }
5.times { create_session(user_id: user.id, room_id: hosted_room.id, joined_at: 1.hour.ago) }
backfill("Crowd Puller")
expect(holders_of("Crowd Puller")).to be_empty
create_session(user_id: 200_000, room_id: hosted_room.id, joined_at: 1.hour.ago)
backfill("Crowd Puller")
expect(holders_of("Crowd Puller")).to contain_exactly(user.id)
end
end
describe "inviting" do
def create_invite(invitee_id, redeemed: true)
Voice::Invite.create!(
room_id: room.id,
user_id: invitee_id,
invited_by_id: user.id,
redeemed_at: redeemed ? Time.current : nil,
)
end
it "grants Connector for ten distinct redeemed invitees" do
9.times { |index| create_invite(100_000 + index) }
create_invite(200_000, redeemed: false)
Voice::Invite.create!(
room_id: Fabricate(:voice_room).id,
user_id: 100_000,
invited_by_id: user.id,
redeemed_at: Time.current,
)
backfill("Connector")
expect(holders_of("Connector")).to be_empty
create_invite(300_000)
backfill("Connector")
expect(holders_of("Connector")).to contain_exactly(user.id)
end
end
describe "Weekend Warrior" do
it "grants for five hours of weekend sessions only" do
saturday = Time.zone.parse("2026-08-29 10:00 UTC")
monday = Time.zone.parse("2026-08-31 10:00 UTC")
create_session(user_id: user.id, joined_at: monday, duration: 6.hours)
create_session(user_id: user.id, joined_at: saturday, duration: 4.hours)
backfill("Weekend Warrior")
expect(holders_of("Weekend Warrior")).to be_empty
create_session(user_id: user.id, joined_at: saturday + 1.day, duration: 1.hour)
backfill("Weekend Warrior")
expect(holders_of("Weekend Warrior")).to contain_exactly(user.id)
end
end
end
@@ -20,29 +20,29 @@ RSpec.describe Voice::BadgeGranterHooks do
end
describe "Mic Check" do
it "grants when session is 30+ seconds and others are in the room" do
other = Fabricate(:user)
Voice::ParticipantTracker.add(room.id, other.id)
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, room: room)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).to include("Mic Check")
end
it "does not grant when session is under 30 seconds" do
other = Fabricate(:user)
Voice::ParticipantTracker.add(room.id, other.id)
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: 20.seconds.ago, left_at: Time.current)
described_class.on_leave(user, session, room: room)
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 room is empty" do
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, room: room)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).not_to include("Mic Check")
end
@@ -56,7 +56,7 @@ RSpec.describe Voice::BadgeGranterHooks do
left = joined + 5.minutes
session = build_session(joined_at: joined, left_at: left)
described_class.on_leave(user, session, room: room)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).to include("Night Owl")
end
@@ -67,7 +67,7 @@ RSpec.describe Voice::BadgeGranterHooks do
left = joined + 5.minutes
session = build_session(joined_at: joined, left_at: left)
described_class.on_leave(user, session, room: room)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).not_to include("Night Owl")
end
@@ -80,23 +80,34 @@ RSpec.describe Voice::BadgeGranterHooks do
left = joined + 5.minutes
session = build_session(joined_at: joined, left_at: left)
described_class.on_leave(user, session, room: room)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).to include("Early Bird")
end
end
describe "Marathoner" do
it "grants when session lasted 4+ hours" 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, room: room)
described_class.on_leave(user, session)
expect(user.badges.pluck(:name)).to include("Marathoner")
end
it "does not grant for shorter sessions" do
session = build_session(joined_at: 3.hours.ago, left_at: Time.current)
described_class.on_leave(user, session, room: room)
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
@@ -106,14 +117,14 @@ RSpec.describe Voice::BadgeGranterHooks do
SiteSetting.voice_badges_enabled = false
session = build_session(joined_at: 5.hours.ago, left_at: Time.current)
described_class.on_leave(user, session, room: room)
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, room: room)
described_class.on_leave(user, session)
expect(user.badges).to be_empty
end
@@ -131,13 +142,16 @@ RSpec.describe Voice::BadgeGranterHooks do
expect(user.badges.pluck(:name)).to include("Packed House")
end
it "does not grant when room has no max_participants" do
it "falls back to the site-wide cap when the room has no max_participants" do
room.update!(max_participants: nil)
participants = User.where(id: [user.id])
described_class.on_join(user, room, participants)
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
@@ -193,6 +207,14 @@ RSpec.describe Voice::BadgeGranterHooks do
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
@@ -228,11 +250,13 @@ RSpec.describe Voice::BadgeGranterHooks do
Badge.joins(:badge_grouping).where(badge_groupings: { name: "Voice" })
end
it "creates all badges as disabled" do
described_class.disable_all!
it "creates all badges enabled" do
expect(voice_badges.count).to eq(27)
expect(voice_badges.where(enabled: true).count).to eq(0)
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
@@ -265,11 +289,23 @@ RSpec.describe Voice::BadgeGranterHooks do
describe ".enable_all!" do
before { described_class.disable_all! }
it "enables all Voice badges" do
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