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", "Bold text") 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 != "Bold text" { t.Errorf("Expected formatted_body 'Bold text', 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) } }