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:
368
consent-service/internal/services/matrix/game_rooms.go
Normal file
368
consent-service/internal/services/matrix/game_rooms.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Breakpilot Drive Game Room Types
|
||||
// ========================================
|
||||
|
||||
// GameMode represents different multiplayer game modes
|
||||
type GameMode string
|
||||
|
||||
const (
|
||||
GameModeSolo GameMode = "solo"
|
||||
GameModeCoop GameMode = "coop" // 2 players, same track
|
||||
GameModeChallenge GameMode = "challenge" // 1v1 competition
|
||||
GameModeClassRace GameMode = "class_race" // Whole class competition
|
||||
)
|
||||
|
||||
// GameRoomConfig holds configuration for game rooms
|
||||
type GameRoomConfig struct {
|
||||
GameMode GameMode `json:"game_mode"`
|
||||
SessionID string `json:"session_id"`
|
||||
HostUserID string `json:"host_user_id"`
|
||||
HostName string `json:"host_name"`
|
||||
ClassName string `json:"class_name,omitempty"`
|
||||
MaxPlayers int `json:"max_players,omitempty"`
|
||||
TeacherIDs []string `json:"teacher_ids,omitempty"`
|
||||
EnableVoice bool `json:"enable_voice,omitempty"`
|
||||
}
|
||||
|
||||
// GameRoom represents an active game room
|
||||
type GameRoom struct {
|
||||
RoomID string `json:"room_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
GameMode GameMode `json:"game_mode"`
|
||||
HostUserID string `json:"host_user_id"`
|
||||
Players []string `json:"players"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// GameEvent represents game events to broadcast
|
||||
type GameEvent struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id"`
|
||||
PlayerID string `json:"player_id"`
|
||||
Data interface{} `json:"data"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// GameEventType constants
|
||||
const (
|
||||
GameEventPlayerJoined = "player_joined"
|
||||
GameEventPlayerLeft = "player_left"
|
||||
GameEventGameStarted = "game_started"
|
||||
GameEventQuizAnswered = "quiz_answered"
|
||||
GameEventScoreUpdate = "score_update"
|
||||
GameEventAchievement = "achievement"
|
||||
GameEventChallengeWon = "challenge_won"
|
||||
GameEventRaceFinished = "race_finished"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Game Room Management
|
||||
// ========================================
|
||||
|
||||
// CreateGameTeamRoom creates a private room for 2-4 players (Co-Op mode)
|
||||
func (s *MatrixService) CreateGameTeamRoom(ctx context.Context, config GameRoomConfig) (*CreateRoomResponse, error) {
|
||||
roomName := fmt.Sprintf("Breakpilot Drive - Team %s", config.SessionID[:8])
|
||||
topic := "Co-Op Spielsession - Arbeitet zusammen!"
|
||||
|
||||
// All players can write
|
||||
users := make(map[string]int)
|
||||
users[s.GenerateUserID(config.HostUserID)] = 50
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: roomName,
|
||||
Topic: topic,
|
||||
Visibility: "private",
|
||||
Preset: "private_chat",
|
||||
InitialState: []StateEvent{
|
||||
{
|
||||
Type: "m.room.encryption",
|
||||
StateKey: "",
|
||||
Content: map[string]string{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
},
|
||||
// Custom game state
|
||||
{
|
||||
Type: "breakpilot.game.session",
|
||||
StateKey: "",
|
||||
Content: map[string]interface{}{
|
||||
"session_id": config.SessionID,
|
||||
"game_mode": string(config.GameMode),
|
||||
"host_id": config.HostUserID,
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
},
|
||||
PowerLevelContentOverride: &PowerLevels{
|
||||
EventsDefault: 0, // All players can send messages
|
||||
UsersDefault: 50,
|
||||
Users: users,
|
||||
Events: map[string]int{
|
||||
"breakpilot.game.event": 0, // Anyone can send game events
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateRoom(ctx, req)
|
||||
}
|
||||
|
||||
// CreateGameChallengeRoom creates a 1v1 challenge room
|
||||
func (s *MatrixService) CreateGameChallengeRoom(ctx context.Context, config GameRoomConfig, challengerID string, opponentID string) (*CreateRoomResponse, error) {
|
||||
roomName := fmt.Sprintf("Challenge: %s", config.SessionID[:8])
|
||||
topic := "1v1 Wettbewerb - Möge der Bessere gewinnen!"
|
||||
|
||||
allPlayers := []string{
|
||||
s.GenerateUserID(challengerID),
|
||||
s.GenerateUserID(opponentID),
|
||||
}
|
||||
|
||||
users := make(map[string]int)
|
||||
for _, id := range allPlayers {
|
||||
users[id] = 50
|
||||
}
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: roomName,
|
||||
Topic: topic,
|
||||
Visibility: "private",
|
||||
Preset: "private_chat",
|
||||
Invite: allPlayers,
|
||||
InitialState: []StateEvent{
|
||||
{
|
||||
Type: "breakpilot.game.session",
|
||||
StateKey: "",
|
||||
Content: map[string]interface{}{
|
||||
"session_id": config.SessionID,
|
||||
"game_mode": string(GameModeChallenge),
|
||||
"challenger_id": challengerID,
|
||||
"opponent_id": opponentID,
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
},
|
||||
PowerLevelContentOverride: &PowerLevels{
|
||||
EventsDefault: 0,
|
||||
UsersDefault: 50,
|
||||
Users: users,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateRoom(ctx, req)
|
||||
}
|
||||
|
||||
// CreateGameClassRaceRoom creates a room for class-wide competition
|
||||
func (s *MatrixService) CreateGameClassRaceRoom(ctx context.Context, config GameRoomConfig) (*CreateRoomResponse, error) {
|
||||
roomName := fmt.Sprintf("Klassenrennen: %s", config.ClassName)
|
||||
topic := fmt.Sprintf("Klassenrennen der %s - Alle gegen alle!", config.ClassName)
|
||||
|
||||
// Teachers get moderator power level
|
||||
users := make(map[string]int)
|
||||
for _, teacherID := range config.TeacherIDs {
|
||||
users[s.GenerateUserID(teacherID)] = 100
|
||||
}
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: roomName,
|
||||
Topic: topic,
|
||||
Visibility: "private",
|
||||
Preset: "private_chat",
|
||||
InitialState: []StateEvent{
|
||||
{
|
||||
Type: "breakpilot.game.session",
|
||||
StateKey: "",
|
||||
Content: map[string]interface{}{
|
||||
"session_id": config.SessionID,
|
||||
"game_mode": string(GameModeClassRace),
|
||||
"class_name": config.ClassName,
|
||||
"teacher_ids": config.TeacherIDs,
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
},
|
||||
PowerLevelContentOverride: &PowerLevels{
|
||||
EventsDefault: 0, // Students can send messages
|
||||
UsersDefault: 10, // Default student level
|
||||
Users: users,
|
||||
Invite: 100, // Only teachers can invite
|
||||
Kick: 100, // Only teachers can kick
|
||||
Events: map[string]int{
|
||||
"breakpilot.game.event": 0, // Anyone can send game events
|
||||
"breakpilot.game.leaderboard": 100, // Only teachers update leaderboard
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateRoom(ctx, req)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Game Event Broadcasting
|
||||
// ========================================
|
||||
|
||||
// SendGameEvent sends a game event to a room
|
||||
func (s *MatrixService) SendGameEvent(ctx context.Context, roomID string, event GameEvent) error {
|
||||
event.Timestamp = time.Now().UTC()
|
||||
|
||||
return s.sendEvent(ctx, roomID, "breakpilot.game.event", event)
|
||||
}
|
||||
|
||||
// SendPlayerJoinedEvent notifies room that a player joined
|
||||
func (s *MatrixService) SendPlayerJoinedEvent(ctx context.Context, roomID string, sessionID string, playerID string, playerName string) error {
|
||||
event := GameEvent{
|
||||
Type: GameEventPlayerJoined,
|
||||
SessionID: sessionID,
|
||||
PlayerID: playerID,
|
||||
Data: map[string]string{
|
||||
"player_name": playerName,
|
||||
},
|
||||
}
|
||||
|
||||
// Also send a visible message
|
||||
msg := fmt.Sprintf("🎮 %s ist dem Spiel beigetreten!", playerName)
|
||||
if err := s.SendMessage(ctx, roomID, msg); err != nil {
|
||||
// Log but don't fail
|
||||
fmt.Printf("Warning: failed to send join message: %v\n", err)
|
||||
}
|
||||
|
||||
return s.SendGameEvent(ctx, roomID, event)
|
||||
}
|
||||
|
||||
// SendScoreUpdateEvent broadcasts score updates
|
||||
func (s *MatrixService) SendScoreUpdateEvent(ctx context.Context, roomID string, sessionID string, playerID string, score int, accuracy float64) error {
|
||||
event := GameEvent{
|
||||
Type: GameEventScoreUpdate,
|
||||
SessionID: sessionID,
|
||||
PlayerID: playerID,
|
||||
Data: map[string]interface{}{
|
||||
"score": score,
|
||||
"accuracy": accuracy,
|
||||
},
|
||||
}
|
||||
|
||||
return s.SendGameEvent(ctx, roomID, event)
|
||||
}
|
||||
|
||||
// SendQuizAnsweredEvent broadcasts when a player answers a quiz
|
||||
func (s *MatrixService) SendQuizAnsweredEvent(ctx context.Context, roomID string, sessionID string, playerID string, correct bool, subject string) error {
|
||||
event := GameEvent{
|
||||
Type: GameEventQuizAnswered,
|
||||
SessionID: sessionID,
|
||||
PlayerID: playerID,
|
||||
Data: map[string]interface{}{
|
||||
"correct": correct,
|
||||
"subject": subject,
|
||||
},
|
||||
}
|
||||
|
||||
return s.SendGameEvent(ctx, roomID, event)
|
||||
}
|
||||
|
||||
// SendAchievementEvent broadcasts when a player earns an achievement
|
||||
func (s *MatrixService) SendAchievementEvent(ctx context.Context, roomID string, sessionID string, playerID string, achievementID string, achievementName string) error {
|
||||
event := GameEvent{
|
||||
Type: GameEventAchievement,
|
||||
SessionID: sessionID,
|
||||
PlayerID: playerID,
|
||||
Data: map[string]interface{}{
|
||||
"achievement_id": achievementID,
|
||||
"achievement_name": achievementName,
|
||||
},
|
||||
}
|
||||
|
||||
// Also send a visible celebration message
|
||||
msg := fmt.Sprintf("🏆 Erfolg freigeschaltet: %s!", achievementName)
|
||||
if err := s.SendMessage(ctx, roomID, msg); err != nil {
|
||||
fmt.Printf("Warning: failed to send achievement message: %v\n", err)
|
||||
}
|
||||
|
||||
return s.SendGameEvent(ctx, roomID, event)
|
||||
}
|
||||
|
||||
// SendChallengeWonEvent broadcasts challenge result
|
||||
func (s *MatrixService) SendChallengeWonEvent(ctx context.Context, roomID string, sessionID string, winnerID string, winnerName string, loserName string, winnerScore int, loserScore int) error {
|
||||
event := GameEvent{
|
||||
Type: GameEventChallengeWon,
|
||||
SessionID: sessionID,
|
||||
PlayerID: winnerID,
|
||||
Data: map[string]interface{}{
|
||||
"winner_name": winnerName,
|
||||
"loser_name": loserName,
|
||||
"winner_score": winnerScore,
|
||||
"loser_score": loserScore,
|
||||
},
|
||||
}
|
||||
|
||||
// Send celebration message
|
||||
msg := fmt.Sprintf("🎉 %s gewinnt gegen %s mit %d zu %d Punkten!", winnerName, loserName, winnerScore, loserScore)
|
||||
if err := s.SendHTMLMessage(ctx, roomID, msg, fmt.Sprintf("<h3>🎉 Challenge beendet!</h3><p><strong>%s</strong> gewinnt gegen %s</p><p>Endstand: %d : %d</p>", winnerName, loserName, winnerScore, loserScore)); err != nil {
|
||||
fmt.Printf("Warning: failed to send challenge result message: %v\n", err)
|
||||
}
|
||||
|
||||
return s.SendGameEvent(ctx, roomID, event)
|
||||
}
|
||||
|
||||
// SendClassRaceLeaderboard broadcasts current leaderboard in class race
|
||||
func (s *MatrixService) SendClassRaceLeaderboard(ctx context.Context, roomID string, sessionID string, leaderboard []map[string]interface{}) error {
|
||||
// Build leaderboard message
|
||||
msg := "🏁 Aktueller Stand:\n"
|
||||
htmlMsg := "<h3>🏁 Aktueller Stand</h3><ol>"
|
||||
|
||||
for i, entry := range leaderboard {
|
||||
if i >= 10 { // Top 10 only
|
||||
break
|
||||
}
|
||||
name := entry["name"].(string)
|
||||
score := entry["score"].(int)
|
||||
msg += fmt.Sprintf("%d. %s - %d Punkte\n", i+1, name, score)
|
||||
htmlMsg += fmt.Sprintf("<li><strong>%s</strong> - %d Punkte</li>", name, score)
|
||||
}
|
||||
htmlMsg += "</ol>"
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, msg, htmlMsg)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Game Room Utilities
|
||||
// ========================================
|
||||
|
||||
// AddPlayerToGameRoom invites and sets up a player in a game room
|
||||
func (s *MatrixService) AddPlayerToGameRoom(ctx context.Context, roomID string, playerMatrixID string, playerName string) error {
|
||||
// Invite the player
|
||||
if err := s.InviteUser(ctx, roomID, playerMatrixID); err != nil {
|
||||
return fmt.Errorf("failed to invite player: %w", err)
|
||||
}
|
||||
|
||||
// Set display name if not already set
|
||||
if err := s.SetDisplayName(ctx, playerMatrixID, playerName); err != nil {
|
||||
// Log but don't fail - display name might already be set
|
||||
fmt.Printf("Warning: failed to set display name: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseGameRoom sends end message and archives the room
|
||||
func (s *MatrixService) CloseGameRoom(ctx context.Context, roomID string, sessionID string) error {
|
||||
// Send closing message
|
||||
msg := "🏁 Spiel beendet! Danke fürs Mitspielen. Dieser Raum wird archiviert."
|
||||
if err := s.SendMessage(ctx, roomID, msg); err != nil {
|
||||
return fmt.Errorf("failed to send closing message: %w", err)
|
||||
}
|
||||
|
||||
// Update room state to mark as closed
|
||||
closeEvent := map[string]interface{}{
|
||||
"closed": true,
|
||||
"closed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return s.sendEvent(ctx, roomID, "breakpilot.game.closed", closeEvent)
|
||||
}
|
||||
548
consent-service/internal/services/matrix/matrix_service.go
Normal file
548
consent-service/internal/services/matrix/matrix_service.go
Normal file
@@ -0,0 +1,548 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MatrixService handles Matrix homeserver communication
|
||||
type MatrixService struct {
|
||||
homeserverURL string
|
||||
accessToken string
|
||||
serverName string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Config holds Matrix service configuration
|
||||
type Config struct {
|
||||
HomeserverURL string // e.g., "http://synapse:8008"
|
||||
AccessToken string // Admin/bot access token
|
||||
ServerName string // e.g., "breakpilot.local"
|
||||
}
|
||||
|
||||
// NewMatrixService creates a new Matrix service instance
|
||||
func NewMatrixService(cfg Config) *MatrixService {
|
||||
return &MatrixService{
|
||||
homeserverURL: cfg.HomeserverURL,
|
||||
accessToken: cfg.AccessToken,
|
||||
serverName: cfg.ServerName,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Matrix API Types
|
||||
// ========================================
|
||||
|
||||
// CreateRoomRequest represents a Matrix room creation request
|
||||
type CreateRoomRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
RoomAliasName string `json:"room_alias_name,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"` // "private" or "public"
|
||||
Preset string `json:"preset,omitempty"` // "private_chat", "public_chat", "trusted_private_chat"
|
||||
IsDirect bool `json:"is_direct,omitempty"`
|
||||
Invite []string `json:"invite,omitempty"`
|
||||
InitialState []StateEvent `json:"initial_state,omitempty"`
|
||||
PowerLevelContentOverride *PowerLevels `json:"power_level_content_override,omitempty"`
|
||||
}
|
||||
|
||||
// CreateRoomResponse represents a Matrix room creation response
|
||||
type CreateRoomResponse struct {
|
||||
RoomID string `json:"room_id"`
|
||||
}
|
||||
|
||||
// StateEvent represents a Matrix state event
|
||||
type StateEvent struct {
|
||||
Type string `json:"type"`
|
||||
StateKey string `json:"state_key"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
// PowerLevels represents Matrix power levels
|
||||
type PowerLevels struct {
|
||||
Ban int `json:"ban,omitempty"`
|
||||
Events map[string]int `json:"events,omitempty"`
|
||||
EventsDefault int `json:"events_default,omitempty"`
|
||||
Invite int `json:"invite,omitempty"`
|
||||
Kick int `json:"kick,omitempty"`
|
||||
Redact int `json:"redact,omitempty"`
|
||||
StateDefault int `json:"state_default,omitempty"`
|
||||
Users map[string]int `json:"users,omitempty"`
|
||||
UsersDefault int `json:"users_default,omitempty"`
|
||||
}
|
||||
|
||||
// SendMessageRequest represents a message to send
|
||||
type SendMessageRequest struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Body string `json:"body"`
|
||||
Format string `json:"format,omitempty"`
|
||||
FormattedBody string `json:"formatted_body,omitempty"`
|
||||
}
|
||||
|
||||
// UserInfo represents Matrix user information
|
||||
type UserInfo struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"displayname,omitempty"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterRequest for user registration
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Admin bool `json:"admin,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterResponse for user registration
|
||||
type RegisterResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
|
||||
// InviteRequest for inviting a user to a room
|
||||
type InviteRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// JoinRequest for joining a room
|
||||
type JoinRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Room Management
|
||||
// ========================================
|
||||
|
||||
// CreateRoom creates a new Matrix room
|
||||
func (s *MatrixService) CreateRoom(ctx context.Context, req CreateRoomRequest) (*CreateRoomResponse, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.doRequest(ctx, "POST", "/_matrix/client/v3/createRoom", body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create room: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, s.parseError(resp)
|
||||
}
|
||||
|
||||
var result CreateRoomResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CreateClassInfoRoom creates a broadcast room for a class (teachers write, parents read)
|
||||
func (s *MatrixService) CreateClassInfoRoom(ctx context.Context, className string, schoolName string, teacherMatrixIDs []string) (*CreateRoomResponse, error) {
|
||||
// Set up power levels: teachers can write (50), parents read-only (0)
|
||||
users := make(map[string]int)
|
||||
for _, teacherID := range teacherMatrixIDs {
|
||||
users[teacherID] = 50
|
||||
}
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: fmt.Sprintf("%s - %s (Info)", className, schoolName),
|
||||
Topic: fmt.Sprintf("Info-Kanal für %s. Nur Lehrer können schreiben.", className),
|
||||
Visibility: "private",
|
||||
Preset: "private_chat",
|
||||
Invite: teacherMatrixIDs,
|
||||
PowerLevelContentOverride: &PowerLevels{
|
||||
EventsDefault: 50, // Only power level 50+ can send messages
|
||||
UsersDefault: 0, // Parents get power level 0 by default
|
||||
Users: users,
|
||||
Invite: 50,
|
||||
Kick: 50,
|
||||
Ban: 50,
|
||||
Redact: 50,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateRoom(ctx, req)
|
||||
}
|
||||
|
||||
// CreateStudentDMRoom creates a direct message room for parent-teacher communication about a student
|
||||
func (s *MatrixService) CreateStudentDMRoom(ctx context.Context, studentName string, className string, teacherMatrixIDs []string, parentMatrixIDs []string) (*CreateRoomResponse, error) {
|
||||
allUsers := append(teacherMatrixIDs, parentMatrixIDs...)
|
||||
|
||||
users := make(map[string]int)
|
||||
for _, id := range allUsers {
|
||||
users[id] = 50 // All can write
|
||||
}
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: fmt.Sprintf("%s (%s) - Dialog", studentName, className),
|
||||
Topic: fmt.Sprintf("Kommunikation über %s", studentName),
|
||||
Visibility: "private",
|
||||
Preset: "trusted_private_chat",
|
||||
IsDirect: false,
|
||||
Invite: allUsers,
|
||||
InitialState: []StateEvent{
|
||||
{
|
||||
Type: "m.room.encryption",
|
||||
StateKey: "",
|
||||
Content: map[string]string{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
},
|
||||
},
|
||||
PowerLevelContentOverride: &PowerLevels{
|
||||
EventsDefault: 0, // Everyone can send messages
|
||||
UsersDefault: 50,
|
||||
Users: users,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateRoom(ctx, req)
|
||||
}
|
||||
|
||||
// CreateParentRepRoom creates a room for class teacher and parent representatives
|
||||
func (s *MatrixService) CreateParentRepRoom(ctx context.Context, className string, teacherMatrixIDs []string, parentRepMatrixIDs []string) (*CreateRoomResponse, error) {
|
||||
allUsers := append(teacherMatrixIDs, parentRepMatrixIDs...)
|
||||
|
||||
users := make(map[string]int)
|
||||
for _, id := range allUsers {
|
||||
users[id] = 50
|
||||
}
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: fmt.Sprintf("%s - Elternvertreter", className),
|
||||
Topic: fmt.Sprintf("Kommunikation zwischen Lehrkräften und Elternvertretern der %s", className),
|
||||
Visibility: "private",
|
||||
Preset: "private_chat",
|
||||
Invite: allUsers,
|
||||
PowerLevelContentOverride: &PowerLevels{
|
||||
EventsDefault: 0,
|
||||
UsersDefault: 50,
|
||||
Users: users,
|
||||
},
|
||||
}
|
||||
|
||||
return s.CreateRoom(ctx, req)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// User Management
|
||||
// ========================================
|
||||
|
||||
// RegisterUser registers a new Matrix user (requires admin token)
|
||||
func (s *MatrixService) RegisterUser(ctx context.Context, username string, displayName string) (*RegisterResponse, error) {
|
||||
// Use admin API for user registration
|
||||
req := map[string]interface{}{
|
||||
"username": username,
|
||||
"password": uuid.New().String(), // Generate random password
|
||||
"admin": false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Use admin registration endpoint
|
||||
resp, err := s.doRequest(ctx, "POST", "/_synapse/admin/v2/users/@"+username+":"+s.serverName, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register user: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, s.parseError(resp)
|
||||
}
|
||||
|
||||
// Set display name
|
||||
if displayName != "" {
|
||||
if err := s.SetDisplayName(ctx, "@"+username+":"+s.serverName, displayName); err != nil {
|
||||
// Log but don't fail
|
||||
fmt.Printf("Warning: failed to set display name: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &RegisterResponse{
|
||||
UserID: "@" + username + ":" + s.serverName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetDisplayName sets the display name for a user
|
||||
func (s *MatrixService) SetDisplayName(ctx context.Context, userID string, displayName string) error {
|
||||
req := map[string]string{
|
||||
"displayname": displayName,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/_matrix/client/v3/profile/%s/displayname", url.PathEscape(userID))
|
||||
resp, err := s.doRequest(ctx, "PUT", endpoint, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set display name: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.parseError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Room Membership
|
||||
// ========================================
|
||||
|
||||
// InviteUser invites a user to a room
|
||||
func (s *MatrixService) InviteUser(ctx context.Context, roomID string, userID string) error {
|
||||
req := InviteRequest{UserID: userID}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/invite", url.PathEscape(roomID))
|
||||
resp, err := s.doRequest(ctx, "POST", endpoint, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to invite user: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.parseError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinRoom makes the bot join a room
|
||||
func (s *MatrixService) JoinRoom(ctx context.Context, roomIDOrAlias string) error {
|
||||
endpoint := fmt.Sprintf("/_matrix/client/v3/join/%s", url.PathEscape(roomIDOrAlias))
|
||||
resp, err := s.doRequest(ctx, "POST", endpoint, []byte("{}"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to join room: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.parseError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserPowerLevel sets a user's power level in a room
|
||||
func (s *MatrixService) SetUserPowerLevel(ctx context.Context, roomID string, userID string, powerLevel int) error {
|
||||
// First, get current power levels
|
||||
endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/state/m.room.power_levels/", url.PathEscape(roomID))
|
||||
resp, err := s.doRequest(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get power levels: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.parseError(resp)
|
||||
}
|
||||
|
||||
var powerLevels PowerLevels
|
||||
if err := json.NewDecoder(resp.Body).Decode(&powerLevels); err != nil {
|
||||
return fmt.Errorf("failed to decode power levels: %w", err)
|
||||
}
|
||||
|
||||
// Update user power level
|
||||
if powerLevels.Users == nil {
|
||||
powerLevels.Users = make(map[string]int)
|
||||
}
|
||||
powerLevels.Users[userID] = powerLevel
|
||||
|
||||
// Send updated power levels
|
||||
body, err := json.Marshal(powerLevels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal power levels: %w", err)
|
||||
}
|
||||
|
||||
resp2, err := s.doRequest(ctx, "PUT", endpoint, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set power levels: %w", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
return s.parseError(resp2)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Messaging
|
||||
// ========================================
|
||||
|
||||
// SendMessage sends a text message to a room
|
||||
func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error {
|
||||
req := SendMessageRequest{
|
||||
MsgType: "m.text",
|
||||
Body: message,
|
||||
}
|
||||
|
||||
return s.sendEvent(ctx, roomID, "m.room.message", req)
|
||||
}
|
||||
|
||||
// SendHTMLMessage sends an HTML-formatted message to a room
|
||||
func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error {
|
||||
req := SendMessageRequest{
|
||||
MsgType: "m.text",
|
||||
Body: plainText,
|
||||
Format: "org.matrix.custom.html",
|
||||
FormattedBody: htmlBody,
|
||||
}
|
||||
|
||||
return s.sendEvent(ctx, roomID, "m.room.message", req)
|
||||
}
|
||||
|
||||
// SendAbsenceNotification sends an absence notification to parents
|
||||
func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error {
|
||||
plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>⚠️ Abwesenheitsmeldung</h3>
|
||||
<p>Ihr Kind <strong>%s</strong> war heute (%s) in der <strong>%d. Stunde</strong> nicht im Unterricht anwesend.</p>
|
||||
<p>Bitte bestätigen Sie den Grund der Abwesenheit.</p>
|
||||
<ul>
|
||||
<li>✅ Entschuldigt (Krankheit)</li>
|
||||
<li>📋 Arztbesuch</li>
|
||||
<li>❓ Sonstiges (bitte erläutern)</li>
|
||||
</ul>`, studentName, date, lessonNumber)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// SendGradeNotification sends a grade notification to parents
|
||||
func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error {
|
||||
plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>📊 Neue Note eingetragen</h3>
|
||||
<p>Für <strong>%s</strong> wurde eine neue Note eingetragen:</p>
|
||||
<table>
|
||||
<tr><td>Fach:</td><td><strong>%s</strong></td></tr>
|
||||
<tr><td>Art:</td><td>%s</td></tr>
|
||||
<tr><td>Note:</td><td><strong>%.1f</strong></td></tr>
|
||||
</table>`, studentName, subject, gradeType, grade)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// SendClassAnnouncement sends an announcement to a class info room
|
||||
func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error {
|
||||
plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>📢 %s</h3>
|
||||
<p>%s</p>
|
||||
<p><em>— %s</em></p>`, title, content, teacherName)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Internal Helpers
|
||||
// ========================================
|
||||
|
||||
func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error {
|
||||
body, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal content: %w", err)
|
||||
}
|
||||
|
||||
txnID := uuid.New().String()
|
||||
endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s",
|
||||
url.PathEscape(roomID), url.PathEscape(eventType), txnID)
|
||||
|
||||
resp, err := s.doRequest(ctx, "PUT", endpoint, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send event: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.parseError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) {
|
||||
fullURL := s.homeserverURL + endpoint
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
return s.httpClient.Do(req)
|
||||
}
|
||||
|
||||
func (s *MatrixService) parseError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp struct {
|
||||
ErrCode string `json:"errcode"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Health Check
|
||||
// ========================================
|
||||
|
||||
// HealthCheck checks if the Matrix server is reachable
|
||||
func (s *MatrixService) HealthCheck(ctx context.Context) error {
|
||||
resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("matrix server returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServerName returns the configured server name
|
||||
func (s *MatrixService) GetServerName() string {
|
||||
return s.serverName
|
||||
}
|
||||
|
||||
// GenerateUserID generates a Matrix user ID from a username
|
||||
func (s *MatrixService) GenerateUserID(username string) string {
|
||||
return fmt.Sprintf("@%s:%s", username, s.serverName)
|
||||
}
|
||||
791
consent-service/internal/services/matrix/matrix_service_test.go
Normal file
791
consent-service/internal/services/matrix/matrix_service_test.go
Normal file
@@ -0,0 +1,791 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Test Helpers
|
||||
// ========================================
|
||||
|
||||
// createTestServer creates a mock Matrix server for testing
|
||||
func createTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *MatrixService) {
|
||||
server := httptest.NewServer(handler)
|
||||
service := NewMatrixService(Config{
|
||||
HomeserverURL: server.URL,
|
||||
AccessToken: "test-access-token",
|
||||
ServerName: "test.local",
|
||||
})
|
||||
return server, service
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Service Creation
|
||||
// ========================================
|
||||
|
||||
func TestNewMatrixService_ValidConfig_CreatesService(t *testing.T) {
|
||||
cfg := Config{
|
||||
HomeserverURL: "http://localhost:8008",
|
||||
AccessToken: "test-token",
|
||||
ServerName: "breakpilot.local",
|
||||
}
|
||||
|
||||
service := NewMatrixService(cfg)
|
||||
|
||||
if service == nil {
|
||||
t.Fatal("Expected service to be created, got nil")
|
||||
}
|
||||
if service.homeserverURL != cfg.HomeserverURL {
|
||||
t.Errorf("Expected homeserverURL %s, got %s", cfg.HomeserverURL, service.homeserverURL)
|
||||
}
|
||||
if service.accessToken != cfg.AccessToken {
|
||||
t.Errorf("Expected accessToken %s, got %s", cfg.AccessToken, service.accessToken)
|
||||
}
|
||||
if service.serverName != cfg.ServerName {
|
||||
t.Errorf("Expected serverName %s, got %s", cfg.ServerName, service.serverName)
|
||||
}
|
||||
if service.httpClient == nil {
|
||||
t.Error("Expected httpClient to be initialized")
|
||||
}
|
||||
if service.httpClient.Timeout != 30*time.Second {
|
||||
t.Errorf("Expected timeout 30s, got %v", service.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServerName_ReturnsConfiguredName(t *testing.T) {
|
||||
service := NewMatrixService(Config{
|
||||
HomeserverURL: "http://localhost:8008",
|
||||
AccessToken: "test-token",
|
||||
ServerName: "school.example.com",
|
||||
})
|
||||
|
||||
result := service.GetServerName()
|
||||
|
||||
if result != "school.example.com" {
|
||||
t.Errorf("Expected 'school.example.com', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUserID_ValidUsername_ReturnsFormattedID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverName string
|
||||
username string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple username",
|
||||
serverName: "breakpilot.local",
|
||||
username: "max.mustermann",
|
||||
expected: "@max.mustermann:breakpilot.local",
|
||||
},
|
||||
{
|
||||
name: "teacher username",
|
||||
serverName: "school.de",
|
||||
username: "lehrer_mueller",
|
||||
expected: "@lehrer_mueller:school.de",
|
||||
},
|
||||
{
|
||||
name: "parent username with numbers",
|
||||
serverName: "test.local",
|
||||
username: "eltern123",
|
||||
expected: "@eltern123:test.local",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := NewMatrixService(Config{
|
||||
HomeserverURL: "http://localhost:8008",
|
||||
AccessToken: "test-token",
|
||||
ServerName: tt.serverName,
|
||||
})
|
||||
|
||||
result := service.GenerateUserID(tt.username)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Health Check
|
||||
// ========================================
|
||||
|
||||
func TestHealthCheck_ServerHealthy_ReturnsNil(t *testing.T) {
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/_matrix/client/versions" {
|
||||
t.Errorf("Expected path /_matrix/client/versions, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("Expected GET method, got %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"versions": []string{"v1.1", "v1.2"},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.HealthCheck(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_ServerUnreachable_ReturnsError(t *testing.T) {
|
||||
service := NewMatrixService(Config{
|
||||
HomeserverURL: "http://localhost:59999", // Non-existent server
|
||||
AccessToken: "test-token",
|
||||
ServerName: "test.local",
|
||||
})
|
||||
|
||||
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, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unreachable") {
|
||||
t.Errorf("Expected 'unreachable' in error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_ServerReturns500_ReturnsError(t *testing.T) {
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.HealthCheck(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for 500 response, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("Expected '500' in error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Room Creation
|
||||
// ========================================
|
||||
|
||||
func TestCreateRoom_ValidRequest_ReturnsRoomID(t *testing.T) {
|
||||
expectedRoomID := "!abc123:test.local"
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/_matrix/client/v3/createRoom" {
|
||||
t.Errorf("Expected path /_matrix/client/v3/createRoom, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Verify authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer test-access-token" {
|
||||
t.Errorf("Expected 'Bearer test-access-token', got '%s'", auth)
|
||||
}
|
||||
|
||||
// Verify content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected 'application/json', got '%s'", contentType)
|
||||
}
|
||||
|
||||
// Decode and verify request body
|
||||
var req CreateRoomRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("Failed to decode request body: %v", err)
|
||||
}
|
||||
|
||||
if req.Name != "Test Room" {
|
||||
t.Errorf("Expected name 'Test Room', got '%s'", req.Name)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(CreateRoomResponse{
|
||||
RoomID: expectedRoomID,
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
req := CreateRoomRequest{
|
||||
Name: "Test Room",
|
||||
Visibility: "private",
|
||||
}
|
||||
|
||||
result, err := service.CreateRoom(context.Background(), req)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if result.RoomID != expectedRoomID {
|
||||
t.Errorf("Expected room ID '%s', got '%s'", expectedRoomID, result.RoomID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoom_ServerError_ReturnsError(t *testing.T) {
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "Not allowed to create rooms",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
req := CreateRoomRequest{Name: "Test"}
|
||||
|
||||
_, err := service.CreateRoom(context.Background(), req)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "M_FORBIDDEN") {
|
||||
t.Errorf("Expected 'M_FORBIDDEN' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClassInfoRoom_ValidInput_CreatesRoomWithCorrectPowerLevels(t *testing.T) {
|
||||
var receivedRequest CreateRoomRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedRequest)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(CreateRoomResponse{RoomID: "!class:test.local"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
teacherIDs := []string{"@lehrer1:test.local", "@lehrer2:test.local"}
|
||||
result, err := service.CreateClassInfoRoom(context.Background(), "5a", "Grundschule Musterstadt", teacherIDs)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if result.RoomID != "!class:test.local" {
|
||||
t.Errorf("Expected room ID '!class:test.local', got '%s'", result.RoomID)
|
||||
}
|
||||
|
||||
// Verify room name format
|
||||
expectedName := "5a - Grundschule Musterstadt (Info)"
|
||||
if receivedRequest.Name != expectedName {
|
||||
t.Errorf("Expected name '%s', got '%s'", expectedName, receivedRequest.Name)
|
||||
}
|
||||
|
||||
// Verify power levels
|
||||
if receivedRequest.PowerLevelContentOverride == nil {
|
||||
t.Fatal("Expected power level override, got nil")
|
||||
}
|
||||
if receivedRequest.PowerLevelContentOverride.EventsDefault != 50 {
|
||||
t.Errorf("Expected EventsDefault 50, got %d", receivedRequest.PowerLevelContentOverride.EventsDefault)
|
||||
}
|
||||
if receivedRequest.PowerLevelContentOverride.UsersDefault != 0 {
|
||||
t.Errorf("Expected UsersDefault 0, got %d", receivedRequest.PowerLevelContentOverride.UsersDefault)
|
||||
}
|
||||
|
||||
// Verify teachers have power level 50
|
||||
for _, teacherID := range teacherIDs {
|
||||
if level, ok := receivedRequest.PowerLevelContentOverride.Users[teacherID]; !ok || level != 50 {
|
||||
t.Errorf("Expected teacher %s to have power level 50, got %d", teacherID, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStudentDMRoom_ValidInput_CreatesEncryptedRoom(t *testing.T) {
|
||||
var receivedRequest CreateRoomRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedRequest)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(CreateRoomResponse{RoomID: "!dm:test.local"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
teacherIDs := []string{"@lehrer:test.local"}
|
||||
parentIDs := []string{"@eltern1:test.local", "@eltern2:test.local"}
|
||||
|
||||
result, err := service.CreateStudentDMRoom(context.Background(), "Max Mustermann", "5a", teacherIDs, parentIDs)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if result.RoomID != "!dm:test.local" {
|
||||
t.Errorf("Expected room ID '!dm:test.local', got '%s'", result.RoomID)
|
||||
}
|
||||
|
||||
// Verify room name
|
||||
expectedName := "Max Mustermann (5a) - Dialog"
|
||||
if receivedRequest.Name != expectedName {
|
||||
t.Errorf("Expected name '%s', got '%s'", expectedName, receivedRequest.Name)
|
||||
}
|
||||
|
||||
// Verify encryption is enabled
|
||||
foundEncryption := false
|
||||
for _, state := range receivedRequest.InitialState {
|
||||
if state.Type == "m.room.encryption" {
|
||||
foundEncryption = true
|
||||
// Content comes as map[string]interface{} from JSON unmarshaling
|
||||
content, ok := state.Content.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected encryption content to be map[string]interface{}, got %T", state.Content)
|
||||
continue
|
||||
}
|
||||
if algo, ok := content["algorithm"].(string); !ok || algo != "m.megolm.v1.aes-sha2" {
|
||||
t.Errorf("Expected algorithm 'm.megolm.v1.aes-sha2', got '%v'", content["algorithm"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundEncryption {
|
||||
t.Error("Expected encryption state event, not found")
|
||||
}
|
||||
|
||||
// Verify all users are invited
|
||||
expectedInvites := append(teacherIDs, parentIDs...)
|
||||
for _, expected := range expectedInvites {
|
||||
found := false
|
||||
for _, invited := range receivedRequest.Invite {
|
||||
if invited == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected user %s to be invited", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateParentRepRoom_ValidInput_CreatesRoom(t *testing.T) {
|
||||
var receivedRequest CreateRoomRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedRequest)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(CreateRoomResponse{RoomID: "!rep:test.local"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
teacherIDs := []string{"@lehrer:test.local"}
|
||||
repIDs := []string{"@elternvertreter1:test.local", "@elternvertreter2:test.local"}
|
||||
|
||||
result, err := service.CreateParentRepRoom(context.Background(), "5a", teacherIDs, repIDs)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if result.RoomID != "!rep:test.local" {
|
||||
t.Errorf("Expected room ID '!rep:test.local', got '%s'", result.RoomID)
|
||||
}
|
||||
|
||||
// Verify room name
|
||||
expectedName := "5a - Elternvertreter"
|
||||
if receivedRequest.Name != expectedName {
|
||||
t.Errorf("Expected name '%s', got '%s'", expectedName, receivedRequest.Name)
|
||||
}
|
||||
|
||||
// Verify all participants can write (power level 50)
|
||||
allUsers := append(teacherIDs, repIDs...)
|
||||
for _, userID := range allUsers {
|
||||
if level, ok := receivedRequest.PowerLevelContentOverride.Users[userID]; !ok || level != 50 {
|
||||
t.Errorf("Expected user %s to have power level 50, got %d", userID, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: User Management
|
||||
// ========================================
|
||||
|
||||
func TestSetDisplayName_ValidRequest_Succeeds(t *testing.T) {
|
||||
var receivedPath string
|
||||
var receivedBody map[string]string
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedPath = r.URL.Path
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.SetDisplayName(context.Background(), "@user:test.local", "Max Mustermann")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Path may or may not be URL-encoded depending on Go version
|
||||
if !strings.Contains(receivedPath, "/profile/") || !strings.Contains(receivedPath, "/displayname") {
|
||||
t.Errorf("Expected path to contain '/profile/' and '/displayname', got '%s'", receivedPath)
|
||||
}
|
||||
|
||||
if receivedBody["displayname"] != "Max Mustermann" {
|
||||
t.Errorf("Expected displayname 'Max Mustermann', got '%s'", receivedBody["displayname"])
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Room Membership
|
||||
// ========================================
|
||||
|
||||
func TestInviteUser_ValidRequest_Succeeds(t *testing.T) {
|
||||
var receivedBody InviteRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "/invite") {
|
||||
t.Errorf("Expected path to contain '/invite', got '%s'", r.URL.Path)
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.InviteUser(context.Background(), "!room:test.local", "@user:test.local")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if receivedBody.UserID != "@user:test.local" {
|
||||
t.Errorf("Expected user_id '@user:test.local', got '%s'", receivedBody.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteUser_UserAlreadyInRoom_ReturnsError(t *testing.T) {
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "User is already in the room",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.InviteUser(context.Background(), "!room:test.local", "@user:test.local")
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinRoom_ValidRequest_Succeeds(t *testing.T) {
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "/join/") {
|
||||
t.Errorf("Expected path to contain '/join/', got '%s'", r.URL.Path)
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"room_id": "!room:test.local"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.JoinRoom(context.Background(), "!room:test.local")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Messaging
|
||||
// ========================================
|
||||
|
||||
func TestSendMessage_ValidRequest_Succeeds(t *testing.T) {
|
||||
var receivedBody SendMessageRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "/send/m.room.message/") {
|
||||
t.Errorf("Expected path to contain '/send/m.room.message/', got '%s'", r.URL.Path)
|
||||
}
|
||||
if r.Method != "PUT" {
|
||||
t.Errorf("Expected PUT method, got %s", r.Method)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.SendMessage(context.Background(), "!room:test.local", "Hello, World!")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if receivedBody.MsgType != "m.text" {
|
||||
t.Errorf("Expected msgtype 'm.text', got '%s'", receivedBody.MsgType)
|
||||
}
|
||||
if receivedBody.Body != "Hello, World!" {
|
||||
t.Errorf("Expected body 'Hello, World!', got '%s'", receivedBody.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendHTMLMessage_ValidRequest_IncludesFormattedBody(t *testing.T) {
|
||||
var receivedBody SendMessageRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.SendHTMLMessage(context.Background(), "!room:test.local", "Plain text", "<b>Bold text</b>")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if receivedBody.Format != "org.matrix.custom.html" {
|
||||
t.Errorf("Expected format 'org.matrix.custom.html', got '%s'", receivedBody.Format)
|
||||
}
|
||||
if receivedBody.Body != "Plain text" {
|
||||
t.Errorf("Expected body 'Plain text', got '%s'", receivedBody.Body)
|
||||
}
|
||||
if receivedBody.FormattedBody != "<b>Bold text</b>" {
|
||||
t.Errorf("Expected formatted_body '<b>Bold text</b>', got '%s'", receivedBody.FormattedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAbsenceNotification_ValidRequest_FormatsCorrectly(t *testing.T) {
|
||||
var receivedBody SendMessageRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.SendAbsenceNotification(context.Background(), "!room:test.local", "Max Mustermann", "15.12.2025", 3)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Verify plain text contains key information
|
||||
if !strings.Contains(receivedBody.Body, "Max Mustermann") {
|
||||
t.Error("Expected body to contain student name")
|
||||
}
|
||||
if !strings.Contains(receivedBody.Body, "15.12.2025") {
|
||||
t.Error("Expected body to contain date")
|
||||
}
|
||||
if !strings.Contains(receivedBody.Body, "3. Stunde") {
|
||||
t.Error("Expected body to contain lesson number")
|
||||
}
|
||||
if !strings.Contains(receivedBody.Body, "Abwesenheitsmeldung") {
|
||||
t.Error("Expected body to contain 'Abwesenheitsmeldung'")
|
||||
}
|
||||
|
||||
// Verify HTML is set
|
||||
if receivedBody.FormattedBody == "" {
|
||||
t.Error("Expected formatted body to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGradeNotification_ValidRequest_FormatsCorrectly(t *testing.T) {
|
||||
var receivedBody SendMessageRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.SendGradeNotification(context.Background(), "!room:test.local", "Max Mustermann", "Mathematik", "Klassenarbeit", 2.3)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(receivedBody.Body, "Max Mustermann") {
|
||||
t.Error("Expected body to contain student name")
|
||||
}
|
||||
if !strings.Contains(receivedBody.Body, "Mathematik") {
|
||||
t.Error("Expected body to contain subject")
|
||||
}
|
||||
if !strings.Contains(receivedBody.Body, "Klassenarbeit") {
|
||||
t.Error("Expected body to contain grade type")
|
||||
}
|
||||
if !strings.Contains(receivedBody.Body, "2.3") {
|
||||
t.Error("Expected body to contain grade")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendClassAnnouncement_ValidRequest_FormatsCorrectly(t *testing.T) {
|
||||
var receivedBody SendMessageRequest
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.SendClassAnnouncement(context.Background(), "!room:test.local", "Elternabend", "Am 20.12. findet der Elternabend statt.", "Frau Müller")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(receivedBody.Body, "Elternabend") {
|
||||
t.Error("Expected body to contain title")
|
||||
}
|
||||
if !strings.Contains(receivedBody.Body, "20.12.") {
|
||||
t.Error("Expected body to contain content")
|
||||
}
|
||||
if !strings.Contains(receivedBody.Body, "Frau Müller") {
|
||||
t.Error("Expected body to contain teacher name")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Power Levels
|
||||
// ========================================
|
||||
|
||||
func TestSetUserPowerLevel_ValidRequest_UpdatesPowerLevel(t *testing.T) {
|
||||
callCount := 0
|
||||
var putBody PowerLevels
|
||||
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if r.Method == "GET" {
|
||||
// Return current power levels
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(PowerLevels{
|
||||
Users: map[string]int{
|
||||
"@admin:test.local": 100,
|
||||
},
|
||||
UsersDefault: 0,
|
||||
})
|
||||
} else if r.Method == "PUT" {
|
||||
// Update power levels
|
||||
json.NewDecoder(r.Body).Decode(&putBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := service.SetUserPowerLevel(context.Background(), "!room:test.local", "@newuser:test.local", 50)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Errorf("Expected 2 API calls (GET then PUT), got %d", callCount)
|
||||
}
|
||||
if putBody.Users["@newuser:test.local"] != 50 {
|
||||
t.Errorf("Expected user power level 50, got %d", putBody.Users["@newuser:test.local"])
|
||||
}
|
||||
// Verify existing users are preserved
|
||||
if putBody.Users["@admin:test.local"] != 100 {
|
||||
t.Errorf("Expected admin power level 100 to be preserved, got %d", putBody.Users["@admin:test.local"])
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Error Handling
|
||||
// ========================================
|
||||
|
||||
func TestParseError_MatrixError_ExtractsFields(t *testing.T) {
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"errcode": "M_UNKNOWN",
|
||||
"error": "Something went wrong",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
_, err := service.CreateRoom(context.Background(), CreateRoomRequest{Name: "Test"})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "M_UNKNOWN") {
|
||||
t.Errorf("Expected error to contain 'M_UNKNOWN', got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Something went wrong") {
|
||||
t.Errorf("Expected error to contain 'Something went wrong', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseError_NonJSONError_ReturnsRawBody(t *testing.T) {
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
_, err := service.CreateRoom(context.Background(), CreateRoomRequest{Name: "Test"})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("Expected error to contain '500', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unit Tests: Context Handling
|
||||
// ========================================
|
||||
|
||||
func TestCreateRoom_ContextCanceled_ReturnsError(t *testing.T) {
|
||||
server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
_, err := service.CreateRoom(ctx, CreateRoomRequest{Name: "Test"})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for canceled context, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Integration Tests (require running Synapse)
|
||||
// ========================================
|
||||
|
||||
// These tests are skipped by default as they require a running Matrix server
|
||||
// Run with: go test -tags=integration ./...
|
||||
|
||||
func TestIntegration_HealthCheck(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
service := NewMatrixService(Config{
|
||||
HomeserverURL: "http://localhost:8008",
|
||||
AccessToken: "", // Not needed for health check
|
||||
ServerName: "breakpilot.local",
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := service.HealthCheck(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("Matrix server not available: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user