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:
Benjamin Boenisch
2026-02-11 23:47:13 +01:00
commit ad111d5e69
244 changed files with 84288 additions and 0 deletions

View 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)
}

View 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)
}

View 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)
}
}