Aggiunge i tornei con hub, pagina pubblica e tabellone per semifinali e finali.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 15:05:57 +02:00
co-authored by Cursor
parent d52434fb2e
commit a1f4c24a43
124 changed files with 6979 additions and 91 deletions
@@ -41,11 +41,15 @@ RSpec.describe "Admin club replays", type: :request do
end
it "elimina un replay" do
storage = instance_double(Recordings::Storage, delete: true)
allow(Recordings::Storage).to receive(:new).and_return(storage)
delete admin_club_recording_path(club, recording)
expect(response).to redirect_to(admin_club_recordings_path(club))
expect(recording.reload.status).to eq("expired")
expect(recording.deleted_at).to be_present
expect(storage).to have_received(:delete).at_least(:once)
end
it "prolunga la scadenza con giorni aggiuntivi" do
+5 -1
View File
@@ -145,9 +145,13 @@ RSpec.describe "Public regia", type: :request do
set_always_available: true,
set_path_recording: true,
delete_path: true,
list_paths: []
list_paths: [],
ensure_path_with_slate!: true,
disable_recording_if_active!: true
)
allow(Mediamtx::Client).to receive(:new).and_return(mtx)
allow(Mediamtx::Client).to receive(:for_session).and_return(mtx)
allow(Mediamtx::PublisherSync).to receive(:remember_recording_off!)
token = Sessions::RegiaAccess.new(session).issue_token!
post public_regia_pause_path(token)
@@ -0,0 +1,783 @@
require "rails_helper"
require "base64"
RSpec.describe "Tournaments", type: :request do
let!(:coach) do
User.create!(email: "torneo-owner@test.it", name: "Owner", password: "Password123", role: "coach")
end
let!(:club) do
c = Club.create!(name: "Torneo Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
ClubMembership.create!(user: coach, club: c, role: "owner")
c
end
let!(:team) { club.teams.create!(name: "U15", sport: "volleyball") }
def login!
post public_login_path, params: { email: coach.email, password: "Password123" }
end
def assign_plan!(slug)
Billing::AssignPlan.call(club: club, plan_slug: slug)
club.reload
end
it "non crea tornei senza Premium Full" do
assign_plan!("premium_light")
login!
expect {
post public_club_tournaments_path(club), params: {
tournament: {
name: "Memorial",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "mixed"
}
}
}.not_to change(Tournament, :count)
expect(response).to redirect_to(public_club_billing_path(club))
end
it "sulla società Free il pulsante Tornei ha il nastrino FULL" do
assign_plan!("free")
login!
get public_club_path(club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("btn-gated__ribbon")
expect(response.body).to include(">FULL<")
expect(response.body).to include(public_club_billing_path(club))
expect(response.body).not_to include(%(href="#{public_club_tournaments_path(club)}"))
end
it "dalla società apre l'elenco tornei, non in dashboard" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Open Elenco",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "free"
}
)
get public_club_path(club)
expect(response.body).to include(public_club_tournaments_path(club))
expect(response.body).not_to include("btn-gated__ribbon")
expect(response.body).not_to include("Open Elenco")
expect(response.body).not_to include(public_new_club_tournament_path(club))
get public_club_tournaments_path(club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Open Elenco")
expect(response.body).to include(public_new_club_tournament_path(club))
expect(response.body).to include(public_tournament_path(tournament))
end
it "crea il torneo, iscrive squadre, genera gironi e pubblica la pagina" do
assign_plan!("premium_full")
login!
expect {
post public_club_tournaments_path(club), params: {
tournament: {
name: "Memorial Estate",
sport_key: "pallavolo",
venue: "Palestra A",
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "mixed",
courts: "Campo 1\nCampo 2",
knockout_size: 4
}
}
}.to change(Tournament, :count).by(1)
tournament = Tournament.order(:created_at).last
expect(tournament.broadcast_team).to be_present
expect(tournament.broadcast_team.tournament_broadcast?).to be(true)
expect(tournament.groups.count).to eq(2)
expect(response).to redirect_to(public_tournament_path(tournament))
a = tournament.groups.order(:position).first
b = tournament.groups.order(:position).last
post public_tournament_participants_path(tournament), params: { tournament_participant: { name: "Alfa", group_id: a.id } }
post public_tournament_participants_path(tournament), params: { tournament_participant: { name: "Beta", group_id: a.id } }
post public_tournament_participants_path(tournament), params: { tournament_participant: { name: "Gamma", group_id: b.id } }
post public_tournament_participants_path(tournament), params: { tournament_participant: { name: "Delta", group_id: b.id } }
expect(tournament.participants.count).to eq(4)
post public_generate_group_matches_tournament_path(tournament)
expect(tournament.matches.count).to be > 0
expect(club.teams.visible.map(&:id)).to include(team.id)
expect(club.teams.visible.map(&:id)).not_to include(tournament.broadcast_team_id)
match = tournament.matches.first
home = match.home_participant
away = match.away_participant
post public_tournament_match_result_path(tournament, match), params: { home_score: 2, away_score: 0, tab: "calendario" }
expect(match.reload.played?).to be(true)
expect(match.winner_participant_id).to eq(home.id)
standings = Tournaments::Standings.call(match.tournament_group)
expect(standings.first.participant).to eq(home)
expect(standings.first.points).to eq(3)
tournament.matches.where.associated(:tournament_group).where.not(id: match.id).find_each do |other|
post public_tournament_match_result_path(tournament, other),
params: { home_score: 2, away_score: 0, tab: "calendario" }
end
post public_propose_knockout_tournament_path(tournament)
final = tournament.rounds.find_by(kind: "final").matches.first
expect(final).to be_present
expect(final.home_source_kind).to eq("winner_match")
expect(final.away_source_kind).to eq("winner_match")
get public_tournament_path(tournament, tab: "tabellone")
expect(response.body).to include("Semifinali e finali")
expect(response.body).to include("Compila tabellone")
expect(response.body).to include("match[home_participant_id]")
semi = tournament.rounds.find_by(kind: "semifinal").matches.first
patch public_tournament_match_path(tournament, semi), params: {
tab: "tabellone",
match: {
home_participant_id: tournament.participants.find_by!(name: "Alfa").id,
away_participant_id: tournament.participants.find_by!(name: "Gamma").id
}
}
expect(response).to redirect_to(public_tournament_path(tournament, tab: "tabellone"))
expect(semi.reload.home_participant.name).to eq("Alfa")
expect(semi.away_participant.name).to eq("Gamma")
tournament.rounds.find_by(kind: "semifinal").matches.each do |semi|
post public_tournament_match_result_path(tournament, semi),
params: { home_score: 2, away_score: 0, tab: "tabellone" }
end
expect(final.reload.home_participant).to be_present
expect(final.away_participant).to be_present
expect(final.home_participant_id).not_to eq(final.away_participant_id)
delete public_logout_path
get public_tournament_page_path(tournament.slug)
expect(response).to have_http_status(:not_found)
login!
post public_publish_tournament_path(tournament)
get public_tournament_page_path(tournament.slug)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Memorial Estate")
expect(response.body).to include(home.name)
expect(response.body).to include("Risultati")
expect(response.body).to include("Tabellone")
get public_tournament_page_path(tournament.slug, tab: "tabellone")
expect(response.body).to include("Classifica")
expect(response.body).to include(home.name)
get public_tournament_pages_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Memorial Estate")
expect(response.body).to include(public_tournament_page_path(tournament.slug))
get public_live_index_path
expect(response.body).to include("Memorial Estate")
expect(response.body).to include("Segui il cartellone")
end
it "delega per campo/giornata e accetta l'invito" do
assign_plan!("premium_full")
login!
post public_club_tournaments_path(club), params: {
tournament: {
name: "Open Day",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo 2"
}
}
tournament = Tournament.order(:created_at).last
home = tournament.participants.create!(name: "Casa")
away = tournament.participants.create!(name: "Ospite")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 2",
scheduled_at: Time.zone.now.change(hour: 10)
}
)
expect {
post public_tournament_invitations_path(tournament), params: {
email: "volontario@test.it",
note: "Porta il treppiede",
email_html: "<p>Testo personalizzato campo 2</p><p>{{link_invito}}</p>",
scope_kind: "court_day",
court: "Campo 2",
on_date: Date.current
}
}.to change { tournament.broadcast_invitations.count }.by(1)
.and change { ActionMailer::Base.deliveries.size }.by(1)
invitation = tournament.broadcast_invitations.last
expect(invitation.note).to eq("Porta il treppiede")
expect(invitation.email_html).to include("Testo personalizzato campo 2")
mail = ActionMailer::Base.deliveries.last
html = (mail.html_part || mail).body.decoded
expect(html).to include("Testo personalizzato campo 2")
expect(html).to include("/join/")
token = nil
follow_redirect!
expect(response.body).to include("/join/")
token = flash[:invite_url].to_s.split("/join/").last
token = CGI.unescapeHTML(response.body[/\/join\/([^"<\s]+)/, 1]) if token.blank?
expect(token).to be_present
volunteer = User.create!(email: "volontario@test.it", name: "Volo", password: "Password123", role: "volunteer")
invitation = tournament.broadcast_invitations.last
invitation.accept!(volunteer)
expect(volunteer.can_broadcast_match?(match)).to be(true)
expect(volunteer.can_broadcast_match?(Match.new(team: team))).to be(false)
end
it "aggiorna orario, campo e logo squadra" do
assign_plan!("premium_full")
login!
post public_club_tournaments_path(club), params: {
tournament: {
name: "Memorial Logo",
sport_key: "pallavolo",
venue: "Palestra A",
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "groups",
courts: "Campo 1\nCampo 2"
}
}
tournament = Tournament.order(:created_at).last
group = tournament.groups.order(:position).first
alfa = tournament.participants.create!(name: "Alfa", group: group)
beta = tournament.participants.create!(name: "Beta", group: group)
post public_generate_group_matches_tournament_path(tournament)
match = tournament.matches.first
new_at = Time.zone.local(Date.current.year, Date.current.month, Date.current.day, 16, 30)
patch public_tournament_match_path(tournament, match), params: {
match: { court: "Campo 2", scheduled_at: new_at.strftime("%Y-%m-%dT%H:%M") }
}
match.reload
expect(match.court).to eq("Campo 2")
expect(match.scheduled_at).to eq(new_at)
expect(match.location).to include("Campo 2")
png = nil
png = Tempfile.new(["logo", ".png"])
png.binmode
png.write(Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="))
png.rewind
patch public_tournament_participant_path(tournament, alfa), params: {
tournament_participant: {
name: "Alfa Blu",
group_id: group.id,
logo_file: Rack::Test::UploadedFile.new(png.path, "image/png", original_filename: "logo.png")
}
}
alfa.reload
expect(alfa.name).to eq("Alfa Blu")
expect(alfa.logo_file).to be_attached
expect(alfa.effective_logo_url).to be_present
if match.away_participant_id == alfa.id
expect(match.reload.opponent_name).to eq("Alfa Blu")
end
ensure
png&.close!
end
it "carica il logo del torneo e lo mostra in pubblico" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Open Logo",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free"
}
)
png = Tempfile.new(["torneo-logo", ".png"])
png.binmode
png.write(Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="))
png.rewind
patch public_tournament_path(tournament), params: {
tournament: { logo_file: Rack::Test::UploadedFile.new(png.path, "image/png", original_filename: "torneo.png") }
}
expect(tournament.reload.logo_file).to be_attached
expect(tournament.effective_logo_url).to be_present
post public_publish_tournament_path(tournament)
get public_tournament_page_path(tournament.slug)
expect(response.body).to include("tournament-public-hero__logo")
expect(response.body).to include(tournament.effective_logo_url)
get public_tournament_pages_path
expect(response.body).to include("tournament-directory-card")
expect(response.body).to include(tournament.effective_logo_url)
ensure
png&.close!
end
it "inverte casa e ospite con un click" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Memorial Swap",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo 1"
}
)
home = tournament.participants.create!(name: "Casa")
away = tournament.participants.create!(name: "Ospite")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 1",
scheduled_at: Time.zone.now.change(hour: 11)
}
)
Tournaments::RecordResult.call(match: match, home_score: 2, away_score: 0, source: "manual")
get public_tournament_path(tournament, tab: "calendario")
expect(response.body).to include("tournament-match-swap__btn")
expect(response.body).to include(public_swap_tournament_match_path(tournament, match))
expect(response.body).to include("tournament-calendar.js")
post public_swap_tournament_match_path(tournament, match), as: :json
expect(response).to have_http_status(:ok)
expect(response).not_to be_redirect
json = JSON.parse(response.body)
expect(json["ok"]).to be(true)
expect(json["matchup_label"]).to include("Ospite")
expect(json["matchup_label"]).to include("Casa")
match.reload
expect(match.home_participant_id).to eq(away.id)
expect(match.away_participant_id).to eq(home.id)
expect(match.home_score).to eq(0)
expect(match.away_score).to eq(2)
expect(match.winner_participant_id).to eq(home.id)
expect(match.opponent_name).to eq("Casa")
end
it "invita e gestisce lo streaming dal piano dirette" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Memorial Streaming",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo 1"
}
)
tournament.update!(
invite_draft_email: "bozza@test.it",
invite_draft_html: "<p>Bozza salvata</p><p>{{link_invito}}</p>",
invite_draft_saved_at: Time.current
)
home = tournament.participants.create!(name: "Casa")
away = tournament.participants.create!(name: "Ospite")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 1",
scheduled_at: Time.zone.now.change(hour: 11)
}
)
other = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: away.id,
away_participant_id: home.id,
court: "Campo 1",
scheduled_at: Time.zone.now.change(hour: 13)
}
)
get public_tournament_path(tournament, tab: "delega")
expect(response).to redirect_to(public_tournament_path(tournament, tab: "dirette"))
get public_tournament_path(tournament, tab: "dirette")
expect(response.body).to include("Piano dirette")
expect(response.body).to include("Invita selezionate")
expect(response.body).to include("plan_match_#{match.id}")
expect(response.body).to include("2 da coprire")
get public_tournament_path(tournament, tab: "calendario")
expect(response.body).not_to include("Da coprire")
expect(response.body).not_to include(">Streaming<")
expect {
post public_tournament_invitations_path(tournament), params: {
email: "op.cal@test.it",
tab: "dirette",
match_ids: [match.id]
}
}.to change { tournament.broadcast_invitations.count }.by(1)
.and change { ActionMailer::Base.deliveries.size }.by(1)
invitation = tournament.broadcast_invitations.last
expect(invitation.scope_kind).to eq("matches")
expect(invitation.email_html).to include("Bozza salvata")
expect(invitation.covers_match?(match)).to be(true)
expect(invitation.covers_match?(other)).to be(false)
follow_redirect!
expect(response.body).to include("Piano dirette")
expect(response.body).to include("tournament-streaming-chip--pending")
expect(response.body).to include("tournament-live-status--waiting_operator")
expect(response.body).not_to include("plan_match_#{match.id}")
expect(response.body).to include("plan_match_#{other.id}")
volunteer = User.create!(email: "op.cal@test.it", name: "Operatore Cal", password: "Password123", role: "volunteer")
invitation.accept!(volunteer)
expect(volunteer.can_broadcast_match?(match)).to be(true)
expect(volunteer.can_broadcast_match?(other)).to be(false)
get public_tournament_path(tournament, tab: "dirette")
expect(response.body).to include("Operatore Cal")
expect(response.body).not_to include("tournament-streaming-chip--pending")
session = StreamSession.create!(match: match, user: volunteer, platform: "matchlivetv", status: "live")
get public_tournament_path(tournament, tab: "dirette")
expect(response.body).to include("tournament-live-status--live")
expect(response.body).to include("In diretta")
session.update_columns(status: "ended", ended_at: Time.current)
get public_tournament_path(tournament, tab: "dirette")
expect(response.body).to include("tournament-live-status--ended")
get public_tournament_path(tournament, tab: "calendario")
expect(response.body).not_to include("Operatore Cal")
expect(response.body).not_to include("Invita selezionate")
assignment = match.broadcast_assignments.find_by!(user: volunteer)
delete public_tournament_assignment_path(tournament, assignment)
follow_redirect!
expect(volunteer.reload.can_broadcast_match?(match)).to be(false)
expect(response.body).to include("Piano dirette")
expect(response.body).to include("tournament-live-status--ended")
expect(response.body).not_to include("Operatore Cal")
end
it "revoca un assegnamento senza togliere le altre gare della stessa delega" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Memorial Court Day",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo 2"
}
)
home = tournament.participants.create!(name: "Casa")
away = tournament.participants.create!(name: "Ospite")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 2",
scheduled_at: Time.zone.now.change(hour: 10)
}
)
other = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: away.id,
away_participant_id: home.id,
court: "Campo 2",
scheduled_at: Time.zone.now.change(hour: 12)
}
)
post public_tournament_invitations_path(tournament), params: {
email: "campo@test.it",
scope_kind: "court_day",
court: "Campo 2",
on_date: Date.current
}
volunteer = User.create!(email: "campo@test.it", name: "Campo Op", password: "Password123", role: "volunteer")
invitation = tournament.broadcast_invitations.last
invitation.accept!(volunteer)
expect(volunteer.can_broadcast_match?(match)).to be(true)
expect(volunteer.can_broadcast_match?(other)).to be(true)
assignment = match.broadcast_assignments.find_by!(user: volunteer)
delete public_tournament_assignment_path(tournament, assignment)
expect(volunteer.reload.can_broadcast_match?(match)).to be(false)
expect(volunteer.can_broadcast_match?(other)).to be(true)
expect(invitation.reload.scope_kind).to eq("matches")
expect(invitation.match_ids.map(&:to_s)).to contain_exactly(other.id.to_s)
end
it "carica un'immagine per la mail di delega" do
assign_plan!("premium_full")
login!
post public_club_tournaments_path(club), params: {
tournament: {
name: "Memorial Mail",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo 1"
}
}
tournament = Tournament.order(:created_at).last
png = Tempfile.new(["invite", ".png"])
png.binmode
png.write(Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="))
png.rewind
post public_tournament_invite_images_path(tournament), params: {
file: Rack::Test::UploadedFile.new(png.path, "image/png", original_filename: "logo.png")
}
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["url"]).to include("/rails/active_storage")
expect(tournament.reload.invite_email_images).to be_attached
ensure
png&.close!
end
it "salva la bozza della mail senza inviarla" do
assign_plan!("premium_full")
login!
post public_club_tournaments_path(club), params: {
tournament: {
name: "Memorial Bozza",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo 1"
}
}
tournament = Tournament.order(:created_at).last
expect {
post public_tournament_invite_draft_path(tournament), params: {
email: "dopo@test.it",
note: "Porta il treppiede",
email_html: %(<p>Testo bozza con {{link_invito}}</p><p><img src="https://www.example.com/logo.png" alt=""></p>)
}
}.not_to change { ActionMailer::Base.deliveries.size }
tournament.reload
expect(tournament.invite_draft_note).to eq("Porta il treppiede")
expect(tournament.invite_draft_email).to eq("dopo@test.it")
expect(tournament.invite_draft_html).to include("Testo bozza")
expect(tournament.invite_draft_html).to include("{{link_invito}}")
expect(tournament.invite_draft_html).to include("logo.png")
expect(tournament.invite_draft_saved_at).to be_present
get public_tournament_path(tournament, tab: "dirette")
expect(response.body).to include("Testo bozza")
expect(response.body).to include("Porta il treppiede")
expect(response.body).to include("dopo@test.it")
expect(response.body).to include("Salva bozza")
expect(response.body).to include("Piano dirette")
expect(response.body).to include("Testo della mail")
expect(response.body).to include("Invita un operatore")
end
it "elimina il torneo, le partite e il team interno" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Memorial da cancellare",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo 1"
}
)
home = tournament.participants.create!(name: "Casa")
away = tournament.participants.create!(name: "Ospite")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 1",
scheduled_at: Time.zone.now.change(hour: 10)
}
)
session = StreamSession.create!(
match: match, user: coach, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago
)
Recording.create!(
stream_session: session, team: tournament.broadcast_team, status: "ready", privacy_status: "public",
title: "Replay torneo",
storage_key: "teams/#{tournament.broadcast_team_id}/sessions/#{session.id}/replay.mp4"
)
team_id = tournament.broadcast_team_id
match_id = match.id
get public_tournament_path(tournament)
expect(response.body).to include("Elimina")
expect {
delete public_tournament_path(tournament)
}.to change(Tournament, :count).by(-1)
.and change(Match, :count).by(-1)
.and change(Team, :count).by(-1)
expect(response).to redirect_to(public_club_tournaments_path(club))
expect(Tournament.find_by(id: tournament.id)).to be_nil
expect(Match.find_by(id: match_id)).to be_nil
expect(Team.find_by(id: team_id)).to be_nil
expect(Recording.find_by(stream_session_id: session.id)).to be_nil
get public_tournament_path(tournament.id)
expect(response).to have_http_status(:not_found)
end
it "non elimina il torneo se una diretta è in corso" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Memorial live",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo 1"
}
)
home = tournament.participants.create!(name: "Casa")
away = tournament.participants.create!(name: "Ospite")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 1",
scheduled_at: Time.zone.now.change(hour: 18)
}
)
StreamSession.create!(match: match, user: coach, platform: "matchlivetv", status: "live")
expect {
delete public_tournament_path(tournament)
}.not_to change(Tournament, :count)
expect(response).to redirect_to(public_tournament_path(tournament))
follow_redirect!
expect(response.body).to include("Chiudi le dirette")
end
it "l'owner elimina il torneo anche senza Premium Full" do
assign_plan!("premium_full")
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Memorial declassato",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free"
}
)
assign_plan!("premium_light")
login!
expect {
delete public_tournament_path(tournament)
}.to change(Tournament, :count).by(-1)
expect(response).to redirect_to(public_club_tournaments_path(club))
end
it "mostra sul cartellone i link a diretta e replay" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Open Cartellone",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "free",
courts: "Campo 1"
}
)
home = tournament.participants.create!(name: "Alfa")
away = tournament.participants.create!(name: "Beta")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 1",
scheduled_at: 2.hours.from_now
}
)
post public_publish_tournament_path(tournament)
session = StreamSession.create!(
match: match, user: coach, platform: "matchlivetv", status: "live", privacy_status: "public"
)
get public_tournament_page_path(tournament.slug)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Risultati")
expect(response.body).to include("Guarda diretta")
expect(response.body).to include(public_live_path(session))
expect(response.body).to include("tournament-live-status--live")
session.update_columns(status: "ended", ended_at: Time.current)
Recording.create!(
stream_session: session, team: tournament.broadcast_team, status: "ready", privacy_status: "public",
title: "Replay Alfa-Beta",
storage_key: "teams/#{tournament.broadcast_team_id}/sessions/#{session.id}/replay.mp4"
)
get public_tournament_page_path(tournament.slug)
expect(response.body).to include("Guarda replay")
expect(response.body).to include(public_replay_path(session.id))
expect(response.body).to include("tournament-live-status--replay")
expect(response.body).to include("Replay Alfa-Beta")
get public_club_tournaments_path(club)
expect(response.body).to include(public_tournament_page_path(tournament.slug))
end
end
@@ -35,6 +35,8 @@ RSpec.describe Recordings::CleanupLocal do
stream_session: session, team: team, status: "ready", privacy_status: "public",
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4"
)
mtx = instance_double(Mediamtx::Client, delete_path: true)
allow(Mediamtx::Client).to receive(:for_session).and_return(mtx)
result = described_class.new(online_paths: []).call
@@ -19,6 +19,11 @@ RSpec.describe Streams::Autoscaler do
redis.del(Streams::DnsProviders::Lab::REDIS_KEY)
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
ENV["STREAM_AUTOSCALE_QUIET_HOURS"] = "off"
end
after do
ENV.delete("STREAM_AUTOSCALE_QUIET_HOURS")
end
it "is a no-op when disabled" do
@@ -261,4 +266,34 @@ RSpec.describe Streams::Autoscaler do
expect(node.reload.status).to eq("ready")
end
end
it "blocks scale-out and warm spare during quiet hours" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_QUIET_HOURS" => "02:00-07:00",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
"STREAM_AUTOSCALE_WARM_SPARE" => "1",
"STREAM_AUTOSCALE_KIND" => "lab",
"STREAM_AUTOSCALE_MAX_NODES" => "5",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "1",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
allow(Streams::QuietHours).to receive(:active?).and_return(true)
home = Streams::NodeRegistry.ensure_home_from_env!
user = User.create!(email: "qh@example.com", name: "Q", password: "Password123", role: "coach")
club = Club.create!(name: "QH", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "qh-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
provisioner = instance_double(Streams::NodeProvisioner)
expect(provisioner).not_to receive(:provision_lab!)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:blocked_capacity)
expect(result.metrics[:quiet_hours]).to eq(true)
end
end
end
@@ -0,0 +1,114 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::NightCloudSweeper do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
def cloud_node!(slug: "ingest-cloud-01")
StreamNode.create!(
slug: slug,
hostname: "#{slug}.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
provider_instance_id: "42",
rtmp_base_url: "rtmp://#{slug}:1935",
hls_base_url: "https://#{slug}/hls",
api_base_url: "http://10.0.0.9:9997",
max_publishers: 6,
max_relays: 6,
metadata: { "public_ip" => "1.2.3.4" }
)
end
before do
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where(role: "cloud").delete_all
end
it "skips outside quiet hours" do
with_env(
"STREAM_NIGHT_SWEEP_ENABLED" => "1",
"STREAM_AUTOSCALE_QUIET_HOURS" => "02:00-07:00",
"STREAM_AUTOSCALE_QUIET_TZ" => "Europe/Rome"
) do
allow(Streams::QuietHours).to receive(:active?).and_return(false)
cloud_node!
result = described_class.sweep!
expect(result.skipped).to eq(true)
expect(StreamNode.where(role: "cloud").count).to eq(1)
end
end
it "decommissions idle cloud nodes during quiet hours" do
with_env("STREAM_NIGHT_SWEEP_ENABLED" => "1") do
allow(Streams::QuietHours).to receive(:active?).and_return(true)
node = cloud_node!
provisioner = instance_double(Streams::NodeProvisioner)
allow(provisioner).to receive(:drain!) do |n|
n.update!(status: "draining")
n
end
allow(provisioner).to receive(:decommission!) do |n|
n.destroy!
true
end
result = described_class.sweep!(provisioner: provisioner)
expect(result.skipped).to eq(false)
expect(result.actions).to include(:"decommission_#{node.slug}")
expect(StreamNode.find_by(id: node.id)).to be_nil
end
end
it "alerts and keeps nodes with active sessions" do
with_env("STREAM_NIGHT_SWEEP_ENABLED" => "1") do
allow(Streams::QuietHours).to receive(:active?).and_return(true)
node = cloud_node!
user = User.create!(email: "night@example.com", name: "N", password: "Password123", role: "coach")
club = Club.create!(name: "Night", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "night-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: node)
allow(Ops::IncidentRecorder).to receive(:record)
provisioner = instance_double(Streams::NodeProvisioner)
expect(provisioner).not_to receive(:decommission!)
result = described_class.sweep!(provisioner: provisioner)
expect(result.actions).to include(:"alert_active_#{node.slug}")
expect(Ops::IncidentRecorder).to have_received(:record).with(hash_including(
fingerprint: "stream_overflow:night_active:#{node.slug}"
))
expect(node.reload.status).to eq("ready")
end
end
it "does not touch lab nodes" do
with_env("STREAM_NIGHT_SWEEP_ENABLED" => "1") do
allow(Streams::QuietHours).to receive(:active?).and_return(true)
StreamNode.create!(
slug: "ingest-lab-night-01",
hostname: "lab-night.local",
role: "lab",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://lab:1935",
hls_base_url: "https://lab/hls",
api_base_url: "http://lab:9997",
max_publishers: 2,
max_relays: 2
)
result = described_class.sweep!
expect(result.actions).to eq([])
expect(StreamNode.find_by(slug: "ingest-lab-night-01")).to be_present
end
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::QuietHours do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
it "is active inside the Rome window" do
with_env(
"STREAM_AUTOSCALE_QUIET_HOURS" => "02:00-07:00",
"STREAM_AUTOSCALE_QUIET_TZ" => "Europe/Rome"
) do
now = Time.find_zone!("Europe/Rome").local(2026, 9, 5, 3, 30)
expect(described_class.active?(now: now)).to eq(true)
end
end
it "is inactive outside the window" do
with_env(
"STREAM_AUTOSCALE_QUIET_HOURS" => "02:00-07:00",
"STREAM_AUTOSCALE_QUIET_TZ" => "Europe/Rome"
) do
now = Time.find_zone!("Europe/Rome").local(2026, 9, 5, 10, 0)
expect(described_class.active?(now: now)).to eq(false)
end
end
it "can be disabled with off" do
with_env("STREAM_AUTOSCALE_QUIET_HOURS" => "off") do
now = Time.find_zone!("Europe/Rome").local(2026, 9, 5, 3, 0)
expect(described_class.active?(now: now)).to eq(false)
end
end
end
@@ -0,0 +1,57 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Tournaments::ComposeInviteEmail do
it "sostituisce i token e rimuove HTML pericoloso" do
html = <<~HTML
<p>Ciao custom</p>
<script>alert(1)</script>
<p><a href="javascript:alert(1)">x</a></p>
<p>{{link_invito}}</p>
<p>Scade il {{scadenza}}</p>
<img src="javascript:alert(1)">
<img src="/rails/active_storage/blobs/redirect/abc/logo.png" width="120">
HTML
out = described_class.call(
html: html,
invite_url: "https://www.example.com/join/token-ok",
expires_on: "10 settembre 2026"
)
expect(out).to include("Ciao custom")
expect(out).to include("https://www.example.com/join/token-ok")
expect(out).to include("10 settembre 2026")
expect(out).to include("/rails/active_storage/blobs/redirect/abc/logo.png")
expect(out).not_to include("<script")
expect(out).not_to include("javascript:")
end
it "aggiunge il link se l'utente ha cancellato il token" do
out = described_class.call(html: "<p>Solo testo</p>", invite_url: "https://www.example.com/join/z")
expect(out).to include("Solo testo")
expect(out).to include("https://www.example.com/join/z")
end
it "mantiene i token del modello quando sanitizza una bozza" do
html = %(<p>Bozza</p><p><a href="{{link_invito}}">Apri</a></p><p data-invite-note="">Nota</p>)
out = described_class.sanitize_html(html)
expect(out).to include("Bozza")
expect(out).to include("{{link_invito}}")
expect(out).to include("Nota")
expect(out).to include("data-invite-note")
end
it "compone il modello predefinito senza chiavi I18n mancanti" do
tournament = instance_double(Tournament, name: "Open", club: instance_double(Club, name: "Tigers"))
invited_by = instance_double(User, name: "Owner")
html = I18n.with_locale(:it) do
described_class.default_html(tournament: tournament, invited_by: invited_by)
end
expect(html).not_to include("Translation missing")
expect(html).to include("Open")
expect(html).to include("Tigers")
expect(html).to include("{{link_invito}}")
end
end