//go:build goolm package infra import ( "context" "encoding/json" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) // fakeSynapseServer crea un httptest.Server que simula Synapse para tests de sync. // syncHandler recibe el numero de llamada /sync (1-indexed) y devuelve la respuesta. // nil response significa bloquear hasta ctx cancelado. func fakeSynapseServer(t *testing.T, syncFn func(call int, w http.ResponseWriter, r *http.Request)) *httptest.Server { t.Helper() var callCount int32 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodPost && r.URL.Path == "/_matrix/client/v3/user/@alice:localhost/filter": // mautrix necesita este endpoint para guardar el filtro; responder con un filter_id _ = json.NewEncoder(w).Encode(map[string]interface{}{"filter_id": "f1"}) case r.URL.Path == "/_matrix/client/v3/sync" || r.URL.Path == "/_matrix/client/r0/sync": n := int(atomic.AddInt32(&callCount, 1)) syncFn(n, w, r) default: w.WriteHeader(http.StatusNotFound) } })) } // syncRespMessage construye una respuesta /sync con un m.room.message. func syncRespMessage(nextBatch string) map[string]interface{} { return map[string]interface{}{ "next_batch": nextBatch, "rooms": map[string]interface{}{ "join": map[string]interface{}{ "!testroom:localhost": map[string]interface{}{ "timeline": map[string]interface{}{ "events": []interface{}{ map[string]interface{}{ "event_id": "$evt001:localhost", "type": "m.room.message", "sender": "@alice:localhost", "origin_server_ts": int64(1700000000000), "room_id": "!testroom:localhost", "content": map[string]interface{}{ "msgtype": "m.text", "body": "hola mundo", }, }, }, "limited": false, }, }, }, }, } } // newTestSyncClient crea un *mautrix.Client apuntando al servidor dado. func newTestSyncClient(t *testing.T, serverURL string) *mautrix.Client { t.Helper() cli, err := mautrix.NewClient(serverURL, "@alice:localhost", "token-test") if err != nil { t.Fatalf("NewClient: %v", err) } cli.UserID = id.UserID("@alice:localhost") return cli } // TestMatrixSyncService_RecibeMensajeYStop arranca el servicio, recibe 1 evento y hace Stop limpio. func TestMatrixSyncService_RecibeMensajeYStop(t *testing.T) { srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) { if n == 1 { _ = json.NewEncoder(w).Encode(syncRespMessage("nb_001")) return } // Bloquear syncs subsiguientes hasta cancelacion <-r.Context().Done() _ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_002"}) }) defer srv.Close() cli := newTestSyncClient(t, srv.URL) h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{ Client: cli, ChannelBuffer: 16, }) if err != nil { t.Fatalf("MatrixSyncService: %v", err) } // Esperar el primer evento select { case ev, ok := <-h.Events: if !ok { t.Fatal("canal cerrado antes de recibir evento") } if ev.Type != "message" { t.Errorf("tipo esperado 'message', got %q", ev.Type) } if ev.Body != "hola mundo" { t.Errorf("body esperado 'hola mundo', got %q", ev.Body) } if ev.Sender != "@alice:localhost" { t.Errorf("sender esperado '@alice:localhost', got %q", ev.Sender) } if ev.RoomID != "!testroom:localhost" { t.Errorf("roomID esperado '!testroom:localhost', got %q", ev.RoomID) } case <-time.After(5 * time.Second): t.Fatal("timeout esperando evento") } // Stop limpio h.Stop() // Verificar que Events cierra tras Stop timeout := time.After(3 * time.Second) for { select { case _, ok := <-h.Events: if !ok { return // canal cerrado correctamente } case <-timeout: t.Fatal("canal Events no cerro tras Stop") } } } // TestMatrixSyncService_BackoffRecovery verifica backoff con 2 errores 500 seguidos de exito. func TestMatrixSyncService_BackoffRecovery(t *testing.T) { srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) { if n <= 2 { // Primeras 2 llamadas: devolver 500 w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(map[string]interface{}{ "errcode": "M_UNKNOWN", "error": "internal server error", }) return } if n == 3 { // Tercera llamada: respuesta correcta inmediata (no bloquear) _ = json.NewEncoder(w).Encode(syncRespMessage("nb_recovery")) return } // Cuarta en adelante: bloquear hasta cancelacion <-r.Context().Done() _ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_x"}) }) defer srv.Close() cli := newTestSyncClient(t, srv.URL) h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{ Client: cli, InitialBackoffMS: 50, // backoff corto para tests MaxBackoffMS: 200, ChannelBuffer: 16, }) if err != nil { t.Fatalf("MatrixSyncService: %v", err) } defer h.Stop() // Tras los fallos, debe llegar el evento de recovery select { case ev, ok := <-h.Events: if !ok { t.Fatal("canal cerrado antes de evento de recovery") } if ev.Type != "message" { t.Errorf("tipo esperado 'message', got %q", ev.Type) } if ev.Body != "hola mundo" { t.Errorf("body esperado 'hola mundo', got %q", ev.Body) } case <-time.After(8 * time.Second): t.Fatal("timeout esperando evento de recovery tras backoff") } } // TestMatrixSyncService_Error401NoExit verifica que 401 emite error pero no cierra el servicio. func TestMatrixSyncService_Error401NoExit(t *testing.T) { srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) { if n == 1 { // Primera llamada: 401 M_UNKNOWN_TOKEN w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]interface{}{ "errcode": "M_UNKNOWN_TOKEN", "error": "Invalid macaroon passed.", }) return } // Bloquear: el servicio espera en backoff maximo <-r.Context().Done() _ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_x"}) }) defer srv.Close() cli := newTestSyncClient(t, srv.URL) h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{ Client: cli, InitialBackoffMS: 50, MaxBackoffMS: 200, ChannelBuffer: 8, }) if err != nil { t.Fatalf("MatrixSyncService: %v", err) } // Debe recibir al menos un error (fatal 401) select { case syncErr := <-h.Errors: if syncErr == nil { t.Error("error esperado no nil") } case <-time.After(4 * time.Second): t.Fatal("timeout esperando error 401 en canal Errors") } // El canal Events NO debe estar cerrado — el servicio sigue activo select { case _, ok := <-h.Events: if !ok { t.Fatal("canal Events no debia cerrarse con error 401 (dejar al caller decidir via Stop)") } case <-time.After(300 * time.Millisecond): // Correcto: canal sigue abierto } h.Stop() // Tras Stop, Events debe cerrarse select { case _, ok := <-h.Events: if !ok { return // OK } case <-time.After(3 * time.Second): t.Fatal("canal Events no cerro tras Stop despues de error 401") } } // TestMatrixSyncService_StopIdempotente verifica que Stop() dos veces no causa panic. func TestMatrixSyncService_StopIdempotente(t *testing.T) { srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) { <-r.Context().Done() _ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_1"}) }) defer srv.Close() cli := newTestSyncClient(t, srv.URL) h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{ Client: cli, }) if err != nil { t.Fatalf("MatrixSyncService: %v", err) } // Llamar Stop dos veces — no debe panic defer func() { if r := recover(); r != nil { t.Errorf("Stop() dos veces causó panic: %v", r) } }() h.Stop() h.Stop() } // TestMatrixSyncService_CtxCancelCierraChannels verifica que cancelar ctx cierra Events < 1s. func TestMatrixSyncService_CtxCancelCierraChannels(t *testing.T) { srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) { <-r.Context().Done() _ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_ctx"}) }) defer srv.Close() ctx, cancel := context.WithCancel(context.Background()) cli := newTestSyncClient(t, srv.URL) h, err := MatrixSyncService(ctx, MatrixSyncServiceConfig{ Client: cli, ChannelBuffer: 4, }) if err != nil { t.Fatalf("MatrixSyncService: %v", err) } // Cancelar contexto padre cancel() // Events debe cerrarse en menos de 1 segundo deadline := time.After(1 * time.Second) for { select { case _, ok := <-h.Events: if !ok { return // canal cerrado correctamente } case <-deadline: t.Fatal("canal Events no cerro en 1s tras cancelar ctx") } } }