Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
340
consent-service/internal/services/jitsi/game_meetings.go
Normal file
340
consent-service/internal/services/jitsi/game_meetings.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package jitsi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Breakpilot Drive Game Meeting Types
|
||||
// ========================================
|
||||
|
||||
// GameMeetingMode represents different game video call modes
|
||||
type GameMeetingMode string
|
||||
|
||||
const (
|
||||
GameMeetingCoop GameMeetingMode = "coop" // Co-Op voice/video
|
||||
GameMeetingChallenge GameMeetingMode = "challenge" // 1v1 face-off
|
||||
GameMeetingClassRace GameMeetingMode = "class_race" // Teacher supervises
|
||||
GameMeetingTeamHuddle GameMeetingMode = "team_huddle" // Quick team sync
|
||||
)
|
||||
|
||||
// GameMeetingConfig holds configuration for game video meetings
|
||||
type GameMeetingConfig struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Mode GameMeetingMode `json:"mode"`
|
||||
HostID string `json:"host_id"`
|
||||
HostName string `json:"host_name"`
|
||||
Players []GamePlayer `json:"players"`
|
||||
EnableVideo bool `json:"enable_video"`
|
||||
EnableVoice bool `json:"enable_voice"`
|
||||
TeacherID string `json:"teacher_id,omitempty"`
|
||||
TeacherName string `json:"teacher_name,omitempty"`
|
||||
ClassName string `json:"class_name,omitempty"`
|
||||
}
|
||||
|
||||
// GamePlayer represents a player in the meeting
|
||||
type GamePlayer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsModerator bool `json:"is_moderator,omitempty"`
|
||||
}
|
||||
|
||||
// GameMeetingLink extends MeetingLink with game-specific info
|
||||
type GameMeetingLink struct {
|
||||
*MeetingLink
|
||||
SessionID string `json:"session_id"`
|
||||
Mode GameMeetingMode `json:"mode"`
|
||||
Players []string `json:"players"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Game Meeting Creation
|
||||
// ========================================
|
||||
|
||||
// CreateCoopMeeting creates a video call for Co-Op gameplay (2-4 players)
|
||||
func (s *JitsiService) CreateCoopMeeting(ctx context.Context, config GameMeetingConfig) (*GameMeetingLink, error) {
|
||||
roomName := fmt.Sprintf("bp-coop-%s", config.SessionID[:8])
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: roomName,
|
||||
DisplayName: config.HostName,
|
||||
Subject: "Breakpilot Drive - Co-Op Session",
|
||||
Moderator: true,
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: !config.EnableVoice,
|
||||
StartWithVideoMuted: !config.EnableVideo,
|
||||
RequireDisplayName: true,
|
||||
EnableLobby: false, // Direct join for co-op
|
||||
DisableDeepLinking: true,
|
||||
},
|
||||
}
|
||||
|
||||
link, err := s.CreateMeetingLink(ctx, meeting)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create co-op meeting: %w", err)
|
||||
}
|
||||
|
||||
playerIDs := make([]string, len(config.Players))
|
||||
for i, p := range config.Players {
|
||||
playerIDs[i] = p.ID
|
||||
}
|
||||
|
||||
return &GameMeetingLink{
|
||||
MeetingLink: link,
|
||||
SessionID: config.SessionID,
|
||||
Mode: GameMeetingCoop,
|
||||
Players: playerIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateChallengeMeeting creates a 1v1 video call for challenges
|
||||
func (s *JitsiService) CreateChallengeMeeting(ctx context.Context, config GameMeetingConfig, challengerName string, opponentName string) (*GameMeetingLink, error) {
|
||||
roomName := fmt.Sprintf("bp-challenge-%s", config.SessionID[:8])
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: roomName,
|
||||
DisplayName: challengerName,
|
||||
Subject: fmt.Sprintf("Challenge: %s vs %s", challengerName, opponentName),
|
||||
Moderator: false, // Both players are equal
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: false, // Voice enabled for trash talk
|
||||
StartWithVideoMuted: !config.EnableVideo,
|
||||
RequireDisplayName: true,
|
||||
EnableLobby: false,
|
||||
DisableDeepLinking: true,
|
||||
},
|
||||
}
|
||||
|
||||
link, err := s.CreateMeetingLink(ctx, meeting)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create challenge meeting: %w", err)
|
||||
}
|
||||
|
||||
return &GameMeetingLink{
|
||||
MeetingLink: link,
|
||||
SessionID: config.SessionID,
|
||||
Mode: GameMeetingChallenge,
|
||||
Players: []string{config.HostID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateClassRaceMeeting creates a video call for teacher-supervised class races
|
||||
func (s *JitsiService) CreateClassRaceMeeting(ctx context.Context, config GameMeetingConfig) (*GameMeetingLink, error) {
|
||||
roomName := fmt.Sprintf("bp-klasse-%s-%s",
|
||||
s.sanitizeRoomName(config.ClassName),
|
||||
time.Now().Format("150405"))
|
||||
|
||||
// Teacher is moderator
|
||||
meeting := Meeting{
|
||||
RoomName: roomName,
|
||||
DisplayName: config.TeacherName,
|
||||
Subject: fmt.Sprintf("Klassenrennen: %s", config.ClassName),
|
||||
Moderator: true,
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: true, // Students muted by default
|
||||
StartWithVideoMuted: true, // Video off for performance
|
||||
RequireDisplayName: true,
|
||||
EnableLobby: true, // Teacher admits students
|
||||
EnableRecording: false, // No recording for minors
|
||||
DisableDeepLinking: true,
|
||||
},
|
||||
Features: &MeetingFeatures{
|
||||
Recording: false,
|
||||
Transcription: false,
|
||||
},
|
||||
}
|
||||
|
||||
link, err := s.CreateMeetingLink(ctx, meeting)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create class race meeting: %w", err)
|
||||
}
|
||||
|
||||
playerIDs := make([]string, len(config.Players))
|
||||
for i, p := range config.Players {
|
||||
playerIDs[i] = p.ID
|
||||
}
|
||||
|
||||
return &GameMeetingLink{
|
||||
MeetingLink: link,
|
||||
SessionID: config.SessionID,
|
||||
Mode: GameMeetingClassRace,
|
||||
Players: playerIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateTeamHuddleMeeting creates a quick sync meeting for teams
|
||||
func (s *JitsiService) CreateTeamHuddleMeeting(ctx context.Context, config GameMeetingConfig, teamName string) (*GameMeetingLink, error) {
|
||||
roomName := fmt.Sprintf("bp-team-%s-%s",
|
||||
s.sanitizeRoomName(teamName),
|
||||
config.SessionID[:8])
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: roomName,
|
||||
DisplayName: config.HostName,
|
||||
Subject: fmt.Sprintf("Team %s - Huddle", teamName),
|
||||
Duration: 5, // Short 5-minute huddles
|
||||
Moderator: true,
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: false, // Voice on for quick sync
|
||||
StartWithVideoMuted: true, // Video optional
|
||||
RequireDisplayName: true,
|
||||
EnableLobby: false,
|
||||
DisableDeepLinking: true,
|
||||
},
|
||||
}
|
||||
|
||||
link, err := s.CreateMeetingLink(ctx, meeting)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create team huddle: %w", err)
|
||||
}
|
||||
|
||||
playerIDs := make([]string, len(config.Players))
|
||||
for i, p := range config.Players {
|
||||
playerIDs[i] = p.ID
|
||||
}
|
||||
|
||||
return &GameMeetingLink{
|
||||
MeetingLink: link,
|
||||
SessionID: config.SessionID,
|
||||
Mode: GameMeetingTeamHuddle,
|
||||
Players: playerIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Game-Specific Meeting Configurations
|
||||
// ========================================
|
||||
|
||||
// GetGameEmbedConfig returns optimized config for embedding in Unity WebGL
|
||||
func (s *JitsiService) GetGameEmbedConfig(enableVideo bool, enableVoice bool) *MeetingConfig {
|
||||
return &MeetingConfig{
|
||||
StartWithAudioMuted: !enableVoice,
|
||||
StartWithVideoMuted: !enableVideo,
|
||||
RequireDisplayName: true,
|
||||
EnableLobby: false,
|
||||
DisableDeepLinking: true, // Important for iframe embedding
|
||||
}
|
||||
}
|
||||
|
||||
// BuildGameEmbedURL creates a URL optimized for Unity WebGL embedding
|
||||
func (s *JitsiService) BuildGameEmbedURL(roomName string, playerName string, enableVideo bool, enableVoice bool) string {
|
||||
config := s.GetGameEmbedConfig(enableVideo, enableVoice)
|
||||
return s.BuildEmbedURL(roomName, playerName, config)
|
||||
}
|
||||
|
||||
// BuildUnityIFrameParams returns parameters for Unity's WebGL iframe
|
||||
func (s *JitsiService) BuildUnityIFrameParams(link *GameMeetingLink, playerName string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"domain": s.extractDomain(),
|
||||
"roomName": link.RoomName,
|
||||
"displayName": playerName,
|
||||
"jwt": link.JWT,
|
||||
"configOverwrite": map[string]interface{}{
|
||||
"startWithAudioMuted": false,
|
||||
"startWithVideoMuted": true,
|
||||
"disableDeepLinking": true,
|
||||
"prejoinPageEnabled": false,
|
||||
"enableWelcomePage": false,
|
||||
"enableClosePage": false,
|
||||
"disableInviteFunctions": true,
|
||||
},
|
||||
"interfaceConfigOverwrite": map[string]interface{}{
|
||||
"DISABLE_JOIN_LEAVE_NOTIFICATIONS": true,
|
||||
"MOBILE_APP_PROMO": false,
|
||||
"SHOW_CHROME_EXTENSION_BANNER": false,
|
||||
"TOOLBAR_BUTTONS": []string{
|
||||
"microphone", "camera", "hangup", "chat",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Spectator Mode (for teachers/parents)
|
||||
// ========================================
|
||||
|
||||
// CreateSpectatorLink creates a view-only link for observers
|
||||
func (s *JitsiService) CreateSpectatorLink(ctx context.Context, roomName string, spectatorName string) (*MeetingLink, error) {
|
||||
meeting := Meeting{
|
||||
RoomName: roomName,
|
||||
DisplayName: fmt.Sprintf("[Zuschauer] %s", spectatorName),
|
||||
Moderator: false,
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: true,
|
||||
StartWithVideoMuted: true,
|
||||
DisableDeepLinking: true,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateMeetingLink(ctx, meeting)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
|
||||
// extractDomain extracts the domain from baseURL
|
||||
func (s *JitsiService) extractDomain() string {
|
||||
// Remove protocol prefix
|
||||
domain := s.baseURL
|
||||
if len(domain) > 8 && domain[:8] == "https://" {
|
||||
domain = domain[8:]
|
||||
} else if len(domain) > 7 && domain[:7] == "http://" {
|
||||
domain = domain[7:]
|
||||
}
|
||||
// Remove port if present
|
||||
for i, c := range domain {
|
||||
if c == ':' || c == '/' {
|
||||
domain = domain[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
// ValidateGameMeetingConfig validates configuration before creating meeting
|
||||
func ValidateGameMeetingConfig(config GameMeetingConfig) error {
|
||||
if config.SessionID == "" {
|
||||
return fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
if config.Mode == "" {
|
||||
return fmt.Errorf("mode is required")
|
||||
}
|
||||
|
||||
if config.HostID == "" {
|
||||
return fmt.Errorf("host_id is required")
|
||||
}
|
||||
|
||||
if config.HostName == "" {
|
||||
return fmt.Errorf("host_name is required")
|
||||
}
|
||||
|
||||
switch config.Mode {
|
||||
case GameMeetingCoop:
|
||||
if len(config.Players) < 2 || len(config.Players) > 4 {
|
||||
return fmt.Errorf("co-op mode requires 2-4 players")
|
||||
}
|
||||
case GameMeetingChallenge:
|
||||
if len(config.Players) != 2 {
|
||||
return fmt.Errorf("challenge mode requires exactly 2 players")
|
||||
}
|
||||
case GameMeetingClassRace:
|
||||
if config.TeacherID == "" || config.TeacherName == "" {
|
||||
return fmt.Errorf("class race mode requires teacher info")
|
||||
}
|
||||
if config.ClassName == "" {
|
||||
return fmt.Errorf("class race mode requires class name")
|
||||
}
|
||||
case GameMeetingTeamHuddle:
|
||||
if len(config.Players) < 2 {
|
||||
return fmt.Errorf("team huddle requires at least 2 players")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown game meeting mode: %s", config.Mode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
566
consent-service/internal/services/jitsi/jitsi_service.go
Normal file
566
consent-service/internal/services/jitsi/jitsi_service.go
Normal file
@@ -0,0 +1,566 @@
|
||||
package jitsi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// JitsiService handles Jitsi Meet integration for video conferences
|
||||
type JitsiService struct {
|
||||
baseURL string
|
||||
appID string
|
||||
appSecret string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Config holds Jitsi service configuration
|
||||
type Config struct {
|
||||
BaseURL string // e.g., "http://localhost:8443"
|
||||
AppID string // Application ID for JWT (optional)
|
||||
AppSecret string // Secret for JWT signing (optional)
|
||||
}
|
||||
|
||||
// NewJitsiService creates a new Jitsi service instance
|
||||
func NewJitsiService(cfg Config) *JitsiService {
|
||||
return &JitsiService{
|
||||
baseURL: strings.TrimSuffix(cfg.BaseURL, "/"),
|
||||
appID: cfg.AppID,
|
||||
appSecret: cfg.AppSecret,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
// Meeting represents a Jitsi meeting configuration
|
||||
type Meeting struct {
|
||||
RoomName string `json:"room_name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
StartTime *time.Time `json:"start_time,omitempty"`
|
||||
Duration int `json:"duration,omitempty"` // in minutes
|
||||
Config *MeetingConfig `json:"config,omitempty"`
|
||||
Moderator bool `json:"moderator,omitempty"`
|
||||
Features *MeetingFeatures `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
// MeetingConfig holds Jitsi room configuration options
|
||||
type MeetingConfig struct {
|
||||
StartWithAudioMuted bool `json:"start_with_audio_muted,omitempty"`
|
||||
StartWithVideoMuted bool `json:"start_with_video_muted,omitempty"`
|
||||
DisableDeepLinking bool `json:"disable_deep_linking,omitempty"`
|
||||
RequireDisplayName bool `json:"require_display_name,omitempty"`
|
||||
EnableLobby bool `json:"enable_lobby,omitempty"`
|
||||
EnableRecording bool `json:"enable_recording,omitempty"`
|
||||
}
|
||||
|
||||
// MeetingFeatures controls which features are enabled
|
||||
type MeetingFeatures struct {
|
||||
Livestreaming bool `json:"livestreaming,omitempty"`
|
||||
Recording bool `json:"recording,omitempty"`
|
||||
Transcription bool `json:"transcription,omitempty"`
|
||||
OutboundCall bool `json:"outbound_call,omitempty"`
|
||||
}
|
||||
|
||||
// MeetingLink contains the generated meeting URL and metadata
|
||||
type MeetingLink struct {
|
||||
URL string `json:"url"`
|
||||
RoomName string `json:"room_name"`
|
||||
JoinURL string `json:"join_url"`
|
||||
ModeratorURL string `json:"moderator_url,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
JWT string `json:"jwt,omitempty"`
|
||||
}
|
||||
|
||||
// JWTClaims represents the JWT payload for Jitsi
|
||||
type JWTClaims struct {
|
||||
Audience string `json:"aud,omitempty"`
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
ExpiresAt int64 `json:"exp,omitempty"`
|
||||
NotBefore int64 `json:"nbf,omitempty"`
|
||||
Context *JWTContext `json:"context,omitempty"`
|
||||
Moderator bool `json:"moderator,omitempty"`
|
||||
Features *JWTFeatures `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
// JWTContext contains user information for JWT
|
||||
type JWTContext struct {
|
||||
User *JWTUser `json:"user,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Callee *JWTCallee `json:"callee,omitempty"`
|
||||
}
|
||||
|
||||
// JWTUser represents user info in JWT
|
||||
type JWTUser struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Moderator bool `json:"moderator,omitempty"`
|
||||
HiddenFromRecorder bool `json:"hidden-from-recorder,omitempty"`
|
||||
}
|
||||
|
||||
// JWTCallee represents callee info (for 1:1 calls)
|
||||
type JWTCallee struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
// JWTFeatures controls JWT-based feature access
|
||||
type JWTFeatures struct {
|
||||
Livestreaming string `json:"livestreaming,omitempty"` // "true" or "false"
|
||||
Recording string `json:"recording,omitempty"`
|
||||
Transcription string `json:"transcription,omitempty"`
|
||||
OutboundCall string `json:"outbound-call,omitempty"`
|
||||
}
|
||||
|
||||
// ScheduledMeeting represents a scheduled training/meeting
|
||||
type ScheduledMeeting struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RoomName string `json:"room_name"`
|
||||
HostID string `json:"host_id"`
|
||||
HostName string `json:"host_name"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Duration int `json:"duration"` // in minutes
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxParticipants int `json:"max_participants,omitempty"`
|
||||
Features *MeetingFeatures `json:"features,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Meeting Management
|
||||
// ========================================
|
||||
|
||||
// CreateMeetingLink generates a meeting URL with optional JWT authentication
|
||||
func (s *JitsiService) CreateMeetingLink(ctx context.Context, meeting Meeting) (*MeetingLink, error) {
|
||||
// Generate room name if not provided
|
||||
roomName := meeting.RoomName
|
||||
if roomName == "" {
|
||||
roomName = s.generateRoomName()
|
||||
}
|
||||
|
||||
// Sanitize room name (Jitsi-compatible)
|
||||
roomName = s.sanitizeRoomName(roomName)
|
||||
|
||||
link := &MeetingLink{
|
||||
RoomName: roomName,
|
||||
URL: fmt.Sprintf("%s/%s", s.baseURL, roomName),
|
||||
JoinURL: fmt.Sprintf("%s/%s", s.baseURL, roomName),
|
||||
Password: meeting.Password,
|
||||
}
|
||||
|
||||
// Generate JWT if authentication is configured
|
||||
if s.appSecret != "" {
|
||||
jwt, expiresAt, err := s.generateJWT(meeting, roomName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate JWT: %w", err)
|
||||
}
|
||||
link.JWT = jwt
|
||||
link.ExpiresAt = expiresAt
|
||||
link.JoinURL = fmt.Sprintf("%s/%s?jwt=%s", s.baseURL, roomName, jwt)
|
||||
|
||||
// Generate moderator URL if user is moderator
|
||||
if meeting.Moderator {
|
||||
link.ModeratorURL = link.JoinURL
|
||||
}
|
||||
}
|
||||
|
||||
// Add config parameters to URL
|
||||
if meeting.Config != nil {
|
||||
params := s.buildConfigParams(meeting.Config)
|
||||
if params != "" {
|
||||
separator := "?"
|
||||
if strings.Contains(link.JoinURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
link.JoinURL += separator + params
|
||||
}
|
||||
}
|
||||
|
||||
return link, nil
|
||||
}
|
||||
|
||||
// CreateTrainingSession creates a meeting link optimized for training sessions
|
||||
func (s *JitsiService) CreateTrainingSession(ctx context.Context, title string, hostName string, hostEmail string, duration int) (*MeetingLink, error) {
|
||||
meeting := Meeting{
|
||||
RoomName: s.generateTrainingRoomName(title),
|
||||
DisplayName: hostName,
|
||||
Email: hostEmail,
|
||||
Subject: title,
|
||||
Duration: duration,
|
||||
Moderator: true,
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: true, // Participants start muted
|
||||
StartWithVideoMuted: false, // Video on for training
|
||||
RequireDisplayName: true, // Know who's attending
|
||||
EnableLobby: true, // Waiting room
|
||||
EnableRecording: true, // Allow recording
|
||||
},
|
||||
Features: &MeetingFeatures{
|
||||
Recording: true,
|
||||
Transcription: false,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateMeetingLink(ctx, meeting)
|
||||
}
|
||||
|
||||
// CreateQuickMeeting creates a simple ad-hoc meeting
|
||||
func (s *JitsiService) CreateQuickMeeting(ctx context.Context, displayName string) (*MeetingLink, error) {
|
||||
meeting := Meeting{
|
||||
DisplayName: displayName,
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: false,
|
||||
StartWithVideoMuted: false,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateMeetingLink(ctx, meeting)
|
||||
}
|
||||
|
||||
// CreateParentTeacherMeeting creates a meeting for parent-teacher conferences
|
||||
func (s *JitsiService) CreateParentTeacherMeeting(ctx context.Context, teacherName string, parentName string, studentName string, scheduledTime time.Time) (*MeetingLink, error) {
|
||||
roomName := fmt.Sprintf("elterngespraech-%s-%s",
|
||||
s.sanitizeRoomName(studentName),
|
||||
scheduledTime.Format("20060102-1504"))
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: roomName,
|
||||
DisplayName: teacherName,
|
||||
Subject: fmt.Sprintf("Elterngespräch: %s", studentName),
|
||||
StartTime: &scheduledTime,
|
||||
Duration: 30, // 30 minutes default
|
||||
Moderator: true,
|
||||
Password: s.generatePassword(),
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: false,
|
||||
StartWithVideoMuted: false,
|
||||
RequireDisplayName: true,
|
||||
EnableLobby: true, // Teacher admits parent
|
||||
DisableDeepLinking: true,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateMeetingLink(ctx, meeting)
|
||||
}
|
||||
|
||||
// CreateClassMeeting creates a meeting for an entire class
|
||||
func (s *JitsiService) CreateClassMeeting(ctx context.Context, className string, teacherName string, subject string) (*MeetingLink, error) {
|
||||
roomName := fmt.Sprintf("klasse-%s-%s",
|
||||
s.sanitizeRoomName(className),
|
||||
time.Now().Format("20060102"))
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: roomName,
|
||||
DisplayName: teacherName,
|
||||
Subject: fmt.Sprintf("%s - %s", className, subject),
|
||||
Moderator: true,
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: true, // Students muted by default
|
||||
StartWithVideoMuted: false,
|
||||
RequireDisplayName: true,
|
||||
EnableLobby: false, // Direct join for classes
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateMeetingLink(ctx, meeting)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// JWT Generation
|
||||
// ========================================
|
||||
|
||||
// generateJWT creates a signed JWT for Jitsi authentication
|
||||
func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) {
|
||||
if s.appSecret == "" {
|
||||
return "", nil, fmt.Errorf("app secret not configured")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Default expiration: 24 hours or based on meeting duration
|
||||
expiration := now.Add(24 * time.Hour)
|
||||
if meeting.Duration > 0 {
|
||||
expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute)
|
||||
}
|
||||
if meeting.StartTime != nil {
|
||||
expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute)
|
||||
}
|
||||
|
||||
claims := JWTClaims{
|
||||
Audience: "jitsi",
|
||||
Issuer: s.appID,
|
||||
Subject: "meet.jitsi",
|
||||
Room: roomName,
|
||||
ExpiresAt: expiration.Unix(),
|
||||
NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period
|
||||
Moderator: meeting.Moderator,
|
||||
Context: &JWTContext{
|
||||
User: &JWTUser{
|
||||
ID: uuid.New().String(),
|
||||
Name: meeting.DisplayName,
|
||||
Email: meeting.Email,
|
||||
Avatar: meeting.Avatar,
|
||||
Moderator: meeting.Moderator,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add features if specified
|
||||
if meeting.Features != nil {
|
||||
claims.Features = &JWTFeatures{
|
||||
Recording: boolToString(meeting.Features.Recording),
|
||||
Livestreaming: boolToString(meeting.Features.Livestreaming),
|
||||
Transcription: boolToString(meeting.Features.Transcription),
|
||||
OutboundCall: boolToString(meeting.Features.OutboundCall),
|
||||
}
|
||||
}
|
||||
|
||||
// Create JWT
|
||||
token, err := s.signJWT(claims)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return token, &expiration, nil
|
||||
}
|
||||
|
||||
// signJWT creates and signs a JWT token
|
||||
func (s *JitsiService) signJWT(claims JWTClaims) (string, error) {
|
||||
// Header
|
||||
header := map[string]string{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Payload
|
||||
payloadJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
|
||||
// Sign
|
||||
message := headerB64 + "." + payloadB64
|
||||
h := hmac.New(sha256.New, []byte(s.appSecret))
|
||||
h.Write([]byte(message))
|
||||
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return message + "." + signature, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Health Check
|
||||
// ========================================
|
||||
|
||||
// HealthCheck verifies the Jitsi server is accessible
|
||||
func (s *JitsiService) HealthCheck(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jitsi server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
return fmt.Errorf("jitsi server error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServerInfo returns information about the Jitsi server
|
||||
func (s *JitsiService) GetServerInfo() map[string]string {
|
||||
return map[string]string{
|
||||
"base_url": s.baseURL,
|
||||
"app_id": s.appID,
|
||||
"auth_enabled": boolToString(s.appSecret != ""),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// URL Building
|
||||
// ========================================
|
||||
|
||||
// BuildEmbedURL creates an embeddable iframe URL
|
||||
func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string {
|
||||
params := url.Values{}
|
||||
|
||||
if displayName != "" {
|
||||
params.Set("userInfo.displayName", displayName)
|
||||
}
|
||||
|
||||
if config != nil {
|
||||
if config.StartWithAudioMuted {
|
||||
params.Set("config.startWithAudioMuted", "true")
|
||||
}
|
||||
if config.StartWithVideoMuted {
|
||||
params.Set("config.startWithVideoMuted", "true")
|
||||
}
|
||||
if config.DisableDeepLinking {
|
||||
params.Set("config.disableDeepLinking", "true")
|
||||
}
|
||||
}
|
||||
|
||||
embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName))
|
||||
if len(params) > 0 {
|
||||
embedURL += "#" + params.Encode()
|
||||
}
|
||||
|
||||
return embedURL
|
||||
}
|
||||
|
||||
// BuildIFrameCode generates HTML iframe code for embedding
|
||||
func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string {
|
||||
if width == 0 {
|
||||
width = 800
|
||||
}
|
||||
if height == 0 {
|
||||
height = 600
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`<iframe src="%s/%s" width="%d" height="%d" allow="camera; microphone; fullscreen; display-capture; autoplay" style="border: 0;"></iframe>`,
|
||||
s.baseURL,
|
||||
s.sanitizeRoomName(roomName),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
|
||||
// generateRoomName creates a unique room name
|
||||
func (s *JitsiService) generateRoomName() string {
|
||||
return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8])
|
||||
}
|
||||
|
||||
// generateTrainingRoomName creates a room name for training sessions
|
||||
func (s *JitsiService) generateTrainingRoomName(title string) string {
|
||||
sanitized := s.sanitizeRoomName(title)
|
||||
if sanitized == "" {
|
||||
sanitized = "schulung"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504"))
|
||||
}
|
||||
|
||||
// sanitizeRoomName removes invalid characters from room names
|
||||
func (s *JitsiService) sanitizeRoomName(name string) string {
|
||||
// Replace spaces and special characters
|
||||
result := strings.ToLower(name)
|
||||
result = strings.ReplaceAll(result, " ", "-")
|
||||
result = strings.ReplaceAll(result, "ä", "ae")
|
||||
result = strings.ReplaceAll(result, "ö", "oe")
|
||||
result = strings.ReplaceAll(result, "ü", "ue")
|
||||
result = strings.ReplaceAll(result, "ß", "ss")
|
||||
|
||||
// Remove any remaining non-alphanumeric characters except hyphen
|
||||
var cleaned strings.Builder
|
||||
for _, r := range result {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
cleaned.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove consecutive hyphens
|
||||
result = cleaned.String()
|
||||
for strings.Contains(result, "--") {
|
||||
result = strings.ReplaceAll(result, "--", "-")
|
||||
}
|
||||
|
||||
// Trim hyphens from start and end
|
||||
result = strings.Trim(result, "-")
|
||||
|
||||
// Limit length
|
||||
if len(result) > 50 {
|
||||
result = result[:50]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// generatePassword creates a random meeting password
|
||||
func (s *JitsiService) generatePassword() string {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
// buildConfigParams creates URL parameters from config
|
||||
func (s *JitsiService) buildConfigParams(config *MeetingConfig) string {
|
||||
params := url.Values{}
|
||||
|
||||
if config.StartWithAudioMuted {
|
||||
params.Set("config.startWithAudioMuted", "true")
|
||||
}
|
||||
if config.StartWithVideoMuted {
|
||||
params.Set("config.startWithVideoMuted", "true")
|
||||
}
|
||||
if config.DisableDeepLinking {
|
||||
params.Set("config.disableDeepLinking", "true")
|
||||
}
|
||||
if config.RequireDisplayName {
|
||||
params.Set("config.requireDisplayName", "true")
|
||||
}
|
||||
if config.EnableLobby {
|
||||
params.Set("config.enableLobby", "true")
|
||||
}
|
||||
|
||||
return params.Encode()
|
||||
}
|
||||
|
||||
// boolToString converts bool to "true"/"false" string
|
||||
func boolToString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// GetBaseURL returns the configured base URL
|
||||
func (s *JitsiService) GetBaseURL() string {
|
||||
return s.baseURL
|
||||
}
|
||||
|
||||
// IsAuthEnabled returns whether JWT authentication is configured
|
||||
func (s *JitsiService) IsAuthEnabled() bool {
|
||||
return s.appSecret != ""
|
||||
}
|
||||
687
consent-service/internal/services/jitsi/jitsi_service_test.go
Normal file
687
consent-service/internal/services/jitsi/jitsi_service_test.go
Normal file
@@ -0,0 +1,687 @@
|
||||
package jitsi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Test Helpers
|
||||
// ========================================
|
||||
|
||||
func createTestService() *JitsiService {
|
||||
return NewJitsiService(Config{
|
||||
BaseURL: "http://localhost:8443",
|
||||
AppID: "breakpilot",
|
||||
AppSecret: "test-secret-key",
|
||||
})
|
||||
}
|
||||
|
||||
func createTestServiceWithoutAuth() *JitsiService {
|
||||
return NewJitsiService(Config{
|
||||
BaseURL: "http://localhost:8443",
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Service Creation
|
||||
// ========================================
|
||||
|
||||
func TestNewJitsiService_ValidConfig_CreatesService(t *testing.T) {
|
||||
cfg := Config{
|
||||
BaseURL: "http://localhost:8443",
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
}
|
||||
|
||||
service := NewJitsiService(cfg)
|
||||
|
||||
if service == nil {
|
||||
t.Fatal("Expected service to be created, got nil")
|
||||
}
|
||||
if service.baseURL != cfg.BaseURL {
|
||||
t.Errorf("Expected baseURL %s, got %s", cfg.BaseURL, service.baseURL)
|
||||
}
|
||||
if service.appID != cfg.AppID {
|
||||
t.Errorf("Expected appID %s, got %s", cfg.AppID, service.appID)
|
||||
}
|
||||
if service.appSecret != cfg.AppSecret {
|
||||
t.Errorf("Expected appSecret %s, got %s", cfg.AppSecret, service.appSecret)
|
||||
}
|
||||
if service.httpClient == nil {
|
||||
t.Error("Expected httpClient to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJitsiService_TrailingSlash_Removed(t *testing.T) {
|
||||
service := NewJitsiService(Config{
|
||||
BaseURL: "http://localhost:8443/",
|
||||
})
|
||||
|
||||
if service.baseURL != "http://localhost:8443" {
|
||||
t.Errorf("Expected trailing slash to be removed, got %s", service.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBaseURL_ReturnsConfiguredURL(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
result := service.GetBaseURL()
|
||||
|
||||
if result != "http://localhost:8443" {
|
||||
t.Errorf("Expected 'http://localhost:8443', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthEnabled_WithSecret_ReturnsTrue(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
if !service.IsAuthEnabled() {
|
||||
t.Error("Expected auth to be enabled when secret is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthEnabled_WithoutSecret_ReturnsFalse(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
if service.IsAuthEnabled() {
|
||||
t.Error("Expected auth to be disabled when secret is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Room Name Generation
|
||||
// ========================================
|
||||
|
||||
func TestSanitizeRoomName_ValidInput_ReturnsCleanName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple name",
|
||||
input: "meeting",
|
||||
expected: "meeting",
|
||||
},
|
||||
{
|
||||
name: "with spaces",
|
||||
input: "My Meeting Room",
|
||||
expected: "my-meeting-room",
|
||||
},
|
||||
{
|
||||
name: "german umlauts",
|
||||
input: "Schüler Müller",
|
||||
expected: "schueler-mueller",
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
input: "Test@#$%Meeting!",
|
||||
expected: "testmeeting",
|
||||
},
|
||||
{
|
||||
name: "consecutive hyphens",
|
||||
input: "test---meeting",
|
||||
expected: "test-meeting",
|
||||
},
|
||||
{
|
||||
name: "leading trailing hyphens",
|
||||
input: "-test-meeting-",
|
||||
expected: "test-meeting",
|
||||
},
|
||||
{
|
||||
name: "eszett",
|
||||
input: "Straße",
|
||||
expected: "strasse",
|
||||
},
|
||||
{
|
||||
name: "numbers",
|
||||
input: "Klasse 5a",
|
||||
expected: "klasse-5a",
|
||||
},
|
||||
}
|
||||
|
||||
service := createTestService()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := service.sanitizeRoomName(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeRoomName_LongName_Truncated(t *testing.T) {
|
||||
service := createTestService()
|
||||
longName := strings.Repeat("a", 100)
|
||||
|
||||
result := service.sanitizeRoomName(longName)
|
||||
|
||||
if len(result) > 50 {
|
||||
t.Errorf("Expected max 50 chars, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRoomName_ReturnsUniqueNames(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
name1 := service.generateRoomName()
|
||||
name2 := service.generateRoomName()
|
||||
|
||||
if name1 == name2 {
|
||||
t.Error("Expected unique room names")
|
||||
}
|
||||
if !strings.HasPrefix(name1, "breakpilot-") {
|
||||
t.Errorf("Expected prefix 'breakpilot-', got '%s'", name1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTrainingRoomName_IncludesTitle(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
result := service.generateTrainingRoomName("Go Workshop")
|
||||
|
||||
if !strings.HasPrefix(result, "go-workshop-") {
|
||||
t.Errorf("Expected to start with 'go-workshop-', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePassword_ReturnsValidPassword(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
password := service.generatePassword()
|
||||
|
||||
if len(password) != 8 {
|
||||
t.Errorf("Expected 8 char password, got %d", len(password))
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Meeting Link Creation
|
||||
// ========================================
|
||||
|
||||
func TestCreateMeetingLink_BasicMeeting_ReturnsValidLink(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: "test-room",
|
||||
DisplayName: "Test User",
|
||||
}
|
||||
|
||||
link, err := service.CreateMeetingLink(context.Background(), meeting)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if link.RoomName != "test-room" {
|
||||
t.Errorf("Expected room name 'test-room', got '%s'", link.RoomName)
|
||||
}
|
||||
if link.URL != "http://localhost:8443/test-room" {
|
||||
t.Errorf("Expected URL 'http://localhost:8443/test-room', got '%s'", link.URL)
|
||||
}
|
||||
if link.JoinURL != "http://localhost:8443/test-room" {
|
||||
t.Errorf("Expected JoinURL 'http://localhost:8443/test-room', got '%s'", link.JoinURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMeetingLink_NoRoomName_GeneratesName(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
meeting := Meeting{
|
||||
DisplayName: "Test User",
|
||||
}
|
||||
|
||||
link, err := service.CreateMeetingLink(context.Background(), meeting)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if link.RoomName == "" {
|
||||
t.Error("Expected room name to be generated")
|
||||
}
|
||||
if !strings.HasPrefix(link.RoomName, "breakpilot-") {
|
||||
t.Errorf("Expected generated room name to start with 'breakpilot-', got '%s'", link.RoomName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMeetingLink_WithPassword_IncludesPassword(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: "test-room",
|
||||
Password: "secret123",
|
||||
}
|
||||
|
||||
link, err := service.CreateMeetingLink(context.Background(), meeting)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if link.Password != "secret123" {
|
||||
t.Errorf("Expected password 'secret123', got '%s'", link.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMeetingLink_WithAuth_IncludesJWT(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: "test-room",
|
||||
DisplayName: "Test User",
|
||||
Email: "test@example.com",
|
||||
}
|
||||
|
||||
link, err := service.CreateMeetingLink(context.Background(), meeting)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if link.JWT == "" {
|
||||
t.Error("Expected JWT to be generated")
|
||||
}
|
||||
if !strings.Contains(link.JoinURL, "jwt=") {
|
||||
t.Error("Expected JoinURL to contain JWT parameter")
|
||||
}
|
||||
if link.ExpiresAt == nil {
|
||||
t.Error("Expected ExpiresAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMeetingLink_WithConfig_IncludesParams(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: "test-room",
|
||||
Config: &MeetingConfig{
|
||||
StartWithAudioMuted: true,
|
||||
StartWithVideoMuted: true,
|
||||
RequireDisplayName: true,
|
||||
},
|
||||
}
|
||||
|
||||
link, err := service.CreateMeetingLink(context.Background(), meeting)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(link.JoinURL, "startWithAudioMuted=true") {
|
||||
t.Error("Expected JoinURL to contain audio muted config")
|
||||
}
|
||||
if !strings.Contains(link.JoinURL, "startWithVideoMuted=true") {
|
||||
t.Error("Expected JoinURL to contain video muted config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMeetingLink_Moderator_SetsModeratorURL(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: "test-room",
|
||||
DisplayName: "Admin",
|
||||
Moderator: true,
|
||||
}
|
||||
|
||||
link, err := service.CreateMeetingLink(context.Background(), meeting)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if link.ModeratorURL == "" {
|
||||
t.Error("Expected ModeratorURL to be set for moderator")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Specialized Meeting Types
|
||||
// ========================================
|
||||
|
||||
func TestCreateTrainingSession_ReturnsOptimizedConfig(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
link, err := service.CreateTrainingSession(
|
||||
context.Background(),
|
||||
"Go Grundlagen",
|
||||
"Max Trainer",
|
||||
"trainer@example.com",
|
||||
60,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(link.RoomName, "go-grundlagen") {
|
||||
t.Errorf("Expected room name to contain 'go-grundlagen', got '%s'", link.RoomName)
|
||||
}
|
||||
// Config should have lobby enabled for training
|
||||
if !strings.Contains(link.JoinURL, "enableLobby=true") {
|
||||
t.Error("Expected training to have lobby enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateQuickMeeting_ReturnsSimpleMeeting(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
link, err := service.CreateQuickMeeting(context.Background(), "Quick User")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if link.RoomName == "" {
|
||||
t.Error("Expected room name to be generated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateParentTeacherMeeting_ReturnsSecureMeeting(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
scheduledTime := time.Now().Add(24 * time.Hour)
|
||||
|
||||
link, err := service.CreateParentTeacherMeeting(
|
||||
context.Background(),
|
||||
"Frau Müller",
|
||||
"Herr Schmidt",
|
||||
"Max Mustermann",
|
||||
scheduledTime,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(link.RoomName, "elterngespraech") {
|
||||
t.Errorf("Expected room name to contain 'elterngespraech', got '%s'", link.RoomName)
|
||||
}
|
||||
if link.Password == "" {
|
||||
t.Error("Expected password for parent-teacher meeting")
|
||||
}
|
||||
if !strings.Contains(link.JoinURL, "enableLobby=true") {
|
||||
t.Error("Expected lobby to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClassMeeting_ReturnsMeetingForClass(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
link, err := service.CreateClassMeeting(
|
||||
context.Background(),
|
||||
"5a",
|
||||
"Herr Lehrer",
|
||||
"Mathematik",
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(link.RoomName, "klasse-5a") {
|
||||
t.Errorf("Expected room name to contain 'klasse-5a', got '%s'", link.RoomName)
|
||||
}
|
||||
// Students should be muted by default
|
||||
if !strings.Contains(link.JoinURL, "startWithAudioMuted=true") {
|
||||
t.Error("Expected students to start muted")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: JWT Generation
|
||||
// ========================================
|
||||
|
||||
func TestGenerateJWT_ValidClaims_ReturnsValidToken(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: "test-room",
|
||||
DisplayName: "Test User",
|
||||
Email: "test@example.com",
|
||||
Moderator: true,
|
||||
Duration: 60,
|
||||
}
|
||||
|
||||
token, expiresAt, err := service.generateJWT(meeting, "test-room")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("Expected token to be generated")
|
||||
}
|
||||
if expiresAt == nil {
|
||||
t.Error("Expected expiration time to be set")
|
||||
}
|
||||
|
||||
// Verify token structure (header.payload.signature)
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Errorf("Expected 3 JWT parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
// Decode and verify payload
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode payload: %v", err)
|
||||
}
|
||||
|
||||
var claims JWTClaims
|
||||
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
|
||||
t.Fatalf("Failed to unmarshal claims: %v", err)
|
||||
}
|
||||
|
||||
if claims.Room != "test-room" {
|
||||
t.Errorf("Expected room 'test-room', got '%s'", claims.Room)
|
||||
}
|
||||
if !claims.Moderator {
|
||||
t.Error("Expected moderator to be true")
|
||||
}
|
||||
if claims.Context == nil || claims.Context.User == nil {
|
||||
t.Error("Expected user context to be set")
|
||||
}
|
||||
if claims.Context.User.Name != "Test User" {
|
||||
t.Errorf("Expected user name 'Test User', got '%s'", claims.Context.User.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJWT_WithFeatures_IncludesFeatures(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
meeting := Meeting{
|
||||
RoomName: "test-room",
|
||||
Features: &MeetingFeatures{
|
||||
Recording: true,
|
||||
Transcription: true,
|
||||
},
|
||||
}
|
||||
|
||||
token, _, err := service.generateJWT(meeting, "test-room")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
payloadJSON, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
|
||||
var claims JWTClaims
|
||||
json.Unmarshal(payloadJSON, &claims)
|
||||
|
||||
if claims.Features == nil {
|
||||
t.Error("Expected features to be set")
|
||||
}
|
||||
if claims.Features.Recording != "true" {
|
||||
t.Errorf("Expected recording 'true', got '%s'", claims.Features.Recording)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJWT_NoSecret_ReturnsError(t *testing.T) {
|
||||
service := createTestServiceWithoutAuth()
|
||||
|
||||
meeting := Meeting{RoomName: "test"}
|
||||
|
||||
_, _, err := service.generateJWT(meeting, "test")
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error when secret is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Health Check
|
||||
// ========================================
|
||||
|
||||
func TestHealthCheck_ServerAvailable_ReturnsNil(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewJitsiService(Config{BaseURL: server.URL})
|
||||
|
||||
err := service.HealthCheck(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_ServerError_ReturnsError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewJitsiService(Config{BaseURL: server.URL})
|
||||
|
||||
err := service.HealthCheck(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for server error response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_ServerUnreachable_ReturnsError(t *testing.T) {
|
||||
service := NewJitsiService(Config{BaseURL: "http://localhost:59999"})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := service.HealthCheck(ctx)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for unreachable server")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: URL Building
|
||||
// ========================================
|
||||
|
||||
func TestBuildEmbedURL_BasicRoom_ReturnsURL(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
url := service.BuildEmbedURL("test-room", "", nil)
|
||||
|
||||
if url != "http://localhost:8443/test-room" {
|
||||
t.Errorf("Expected 'http://localhost:8443/test-room', got '%s'", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmbedURL_WithDisplayName_IncludesParam(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
url := service.BuildEmbedURL("test-room", "Max Mustermann", nil)
|
||||
|
||||
if !strings.Contains(url, "displayName=Max") {
|
||||
t.Errorf("Expected URL to contain display name, got '%s'", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmbedURL_WithConfig_IncludesParams(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
config := &MeetingConfig{
|
||||
StartWithAudioMuted: true,
|
||||
StartWithVideoMuted: true,
|
||||
}
|
||||
|
||||
url := service.BuildEmbedURL("test-room", "", config)
|
||||
|
||||
if !strings.Contains(url, "startWithAudioMuted=true") {
|
||||
t.Error("Expected URL to contain audio muted config")
|
||||
}
|
||||
if !strings.Contains(url, "startWithVideoMuted=true") {
|
||||
t.Error("Expected URL to contain video muted config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIFrameCode_DefaultSize_Returns800x600(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
code := service.BuildIFrameCode("test-room", 0, 0)
|
||||
|
||||
if !strings.Contains(code, "width=\"800\"") {
|
||||
t.Error("Expected default width 800")
|
||||
}
|
||||
if !strings.Contains(code, "height=\"600\"") {
|
||||
t.Error("Expected default height 600")
|
||||
}
|
||||
if !strings.Contains(code, "test-room") {
|
||||
t.Error("Expected room name in iframe")
|
||||
}
|
||||
if !strings.Contains(code, "allow=\"camera; microphone") {
|
||||
t.Error("Expected camera/microphone permissions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIFrameCode_CustomSize_ReturnsCorrectDimensions(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
code := service.BuildIFrameCode("test-room", 1920, 1080)
|
||||
|
||||
if !strings.Contains(code, "width=\"1920\"") {
|
||||
t.Error("Expected width 1920")
|
||||
}
|
||||
if !strings.Contains(code, "height=\"1080\"") {
|
||||
t.Error("Expected height 1080")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Server Info
|
||||
// ========================================
|
||||
|
||||
func TestGetServerInfo_ReturnsInfo(t *testing.T) {
|
||||
service := createTestService()
|
||||
|
||||
info := service.GetServerInfo()
|
||||
|
||||
if info["base_url"] != "http://localhost:8443" {
|
||||
t.Errorf("Expected base_url, got '%s'", info["base_url"])
|
||||
}
|
||||
if info["app_id"] != "breakpilot" {
|
||||
t.Errorf("Expected app_id 'breakpilot', got '%s'", info["app_id"])
|
||||
}
|
||||
if info["auth_enabled"] != "true" {
|
||||
t.Errorf("Expected auth_enabled 'true', got '%s'", info["auth_enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Helper Functions
|
||||
// ========================================
|
||||
|
||||
func TestBoolToString_True_ReturnsTrue(t *testing.T) {
|
||||
result := boolToString(true)
|
||||
if result != "true" {
|
||||
t.Errorf("Expected 'true', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoolToString_False_ReturnsFalse(t *testing.T) {
|
||||
result := boolToString(false)
|
||||
if result != "false" {
|
||||
t.Errorf("Expected 'false', got '%s'", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user