diff --git a/db/structure.sql b/db/structure.sql index b3e29002634..2169b8ffa09 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -24410,6 +24410,7 @@ SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES ('20260904065041'), ('20260904063128'), +('20260903195501'), ('20260903065141'), ('20260902150024'), ('20260901020329'), diff --git a/plugins/voice/app/controllers/voice/rooms_controller.rb b/plugins/voice/app/controllers/voice/rooms_controller.rb index 832d0a6f314..68f1ad61a5d 100644 --- a/plugins/voice/app/controllers/voice/rooms_controller.rb +++ b/plugins/voice/app/controllers/voice/rooms_controller.rb @@ -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) diff --git a/plugins/voice/app/jobs/scheduled/voice/close_orphaned_sessions.rb b/plugins/voice/app/jobs/scheduled/voice/close_orphaned_sessions.rb index 848c297dfc0..4fe2f1a1999 100644 --- a/plugins/voice/app/jobs/scheduled/voice/close_orphaned_sessions.rb +++ b/plugins/voice/app/jobs/scheduled/voice/close_orphaned_sessions.rb @@ -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 diff --git a/plugins/voice/app/models/voice/session.rb b/plugins/voice/app/models/voice/session.rb index e2746767110..3bc89d33be3 100644 --- a/plugins/voice/app/models/voice/session.rb +++ b/plugins/voice/app/models/voice/session.rb @@ -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 diff --git a/plugins/voice/app/services/voice/badge_granter_hooks.rb b/plugins/voice/app/services/voice/badge_granter_hooks.rb index 616416eb1ce..80bb3f2994c 100644 --- a/plugins/voice/app/services/voice/badge_granter_hooks.rb +++ b/plugins/voice/app/services/voice/badge_granter_hooks.rb @@ -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 diff --git a/plugins/voice/config/locales/server.en.yml b/plugins/voice/config/locales/server.en.yml index 6e0f2e5aba4..dad0d652e0c 100644 --- a/plugins/voice/config/locales/server.en.yml +++ b/plugins/voice/config/locales/server.en.yml @@ -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." diff --git a/plugins/voice/config/settings.yml b/plugins/voice/config/settings.yml index 8a5cdb0300d..13e3fe2e2f1 100644 --- a/plugins/voice/config/settings.yml +++ b/plugins/voice/config/settings.yml @@ -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: diff --git a/plugins/voice/db/fixtures/001_badges.rb b/plugins/voice/db/fixtures/001_badges.rb index 16bea80978a..61a8b0c52d5 100644 --- a/plugins/voice/db/fixtures/001_badges.rb +++ b/plugins/voice/db/fixtures/001_badges.rb @@ -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 diff --git a/plugins/voice/db/post_migrate/20260903195501_enable_voice_badges_by_default.rb b/plugins/voice/db/post_migrate/20260903195501_enable_voice_badges_by_default.rb new file mode 100644 index 00000000000..a5f94798dc8 --- /dev/null +++ b/plugins/voice/db/post_migrate/20260903195501_enable_voice_badges_by_default.rb @@ -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 diff --git a/plugins/voice/spec/jobs/scheduled/voice/purge_old_sessions_spec.rb b/plugins/voice/spec/jobs/scheduled/voice/purge_old_sessions_spec.rb new file mode 100644 index 00000000000..2fb1039664e --- /dev/null +++ b/plugins/voice/spec/jobs/scheduled/voice/purge_old_sessions_spec.rb @@ -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 diff --git a/plugins/voice/spec/migrations/enable_voice_badges_by_default_spec.rb b/plugins/voice/spec/migrations/enable_voice_badges_by_default_spec.rb new file mode 100644 index 00000000000..dad7b2e9171 --- /dev/null +++ b/plugins/voice/spec/migrations/enable_voice_badges_by_default_spec.rb @@ -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 diff --git a/plugins/voice/spec/models/voice/session_spec.rb b/plugins/voice/spec/models/voice/session_spec.rb new file mode 100644 index 00000000000..7f638faf1ba --- /dev/null +++ b/plugins/voice/spec/models/voice/session_spec.rb @@ -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 diff --git a/plugins/voice/spec/services/voice/badge_backfill_spec.rb b/plugins/voice/spec/services/voice/badge_backfill_spec.rb new file mode 100644 index 00000000000..ee9e26b9f12 --- /dev/null +++ b/plugins/voice/spec/services/voice/badge_backfill_spec.rb @@ -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 diff --git a/plugins/voice/spec/services/voice/badge_granter_hooks_spec.rb b/plugins/voice/spec/services/voice/badge_granter_hooks_spec.rb index f069d38ba72..f4251ec49b7 100644 --- a/plugins/voice/spec/services/voice/badge_granter_hooks_spec.rb +++ b/plugins/voice/spec/services/voice/badge_granter_hooks_spec.rb @@ -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