diff --git a/go.mod b/go.mod index ec5db72..73e5171 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.22.0 + github.com/stretchr/testify v1.10.0 github.com/vektah/gqlparser/v2 v2.5.30 golang.org/x/crypto v0.40.0 google.golang.org/grpc v1.74.2 @@ -23,18 +24,23 @@ require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/sosodev/duration v1.3.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d82ef57..3943ff7 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,7 @@ github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0 github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -51,6 +52,10 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -67,11 +72,15 @@ github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2 github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= @@ -105,6 +114,8 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/http/graph/messages_resolvers_test.go b/internal/http/graph/messages_resolvers_test.go new file mode 100644 index 0000000..c2e0647 --- /dev/null +++ b/internal/http/graph/messages_resolvers_test.go @@ -0,0 +1,283 @@ +package graph + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "tailly_back_v2/proto" +) + +const ( + grpcAddress = "localhost:50052" + userIDKey = "userID" +) + +func setupIntegrationTest(t *testing.T) *Resolver { + // Создаем соединение с gRPC сервером + conn, err := grpc.Dial(grpcAddress, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err, "failed to connect to gRPC server") + + return &Resolver{ + MessageClient: proto.NewMessageServiceClient(conn), + } +} + +func TestIntegration_CreateAndSendMessages(t *testing.T) { + r := setupIntegrationTest(t) + ctx := context.Background() + + // Тест создания чата + t.Run("CreateChat", func(t *testing.T) { + resolver := &mutationResolver{r} + chat, err := resolver.CreateChat(ctx, 1, 2) + require.NoError(t, err) + assert.NotZero(t, chat.ID) + assert.Equal(t, 1, chat.User1ID) + assert.Equal(t, 2, chat.User2ID) + }) + + // Тест отправки сообщений + t.Run("SendMessages", func(t *testing.T) { + resolver := &mutationResolver{r} + + // Сначала создаем чат + chat, err := resolver.CreateChat(ctx, 1, 2) + require.NoError(t, err) + + // Отправляем 5 сообщений + for i := 0; i < 5; i++ { + msg, err := resolver.SendMessage( + context.WithValue(ctx, userIDKey, 1), // Добавляем senderID в контекст + int(chat.ID), + "test message "+string(rune('A'+i)), + ) + require.NoError(t, err) + assert.NotZero(t, msg.ID) + assert.Equal(t, "test message "+string(rune('A'+i)), msg.Content) + } + }) +} + +func TestIntegration_MessageStream(t *testing.T) { + r := setupIntegrationTest(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Используем существующих пользователей + const user1ID = 5 + const user2ID = 12 + + // Создаем чат + mutation := &mutationResolver{r} + chat, err := mutation.CreateChat(ctx, user1ID, user2ID) + require.NoError(t, err, "Failed to create chat") + + // Канал для синхронизации завершения теста + testDone := make(chan struct{}) + defer close(testDone) + + // Канал для получения ошибок из горутин + errChan := make(chan error, 1) + + // Запускаем подписку на сообщения + subscription := &subscriptionResolver{r} + msgChan, err := subscription.MessageStream(ctx, user2ID) + require.NoError(t, err, "Failed to create message stream") + + // Отправляем 5 сообщений + go func() { + defer func() { + if r := recover(); r != nil { + t.Logf("Recovered in sender goroutine: %v", r) + } + }() + + for i := 0; i < 5; i++ { + select { + case <-testDone: + return + default: + _, err := mutation.SendMessage( + context.WithValue(ctx, userIDKey, user1ID), + int(chat.ID), + fmt.Sprintf("stream message %d", i+1), + ) + if err != nil { + select { + case errChan <- fmt.Errorf("failed to send message: %w", err): + default: + } + return + } + time.Sleep(500 * time.Millisecond) // Увеличиваем задержку + } + } + }() + + // Получаем сообщения из стрима + received := 0 + var lastErr error + + for { + select { + case err := <-errChan: + lastErr = err + break + case msg, ok := <-msgChan: + if !ok { + break + } + if msg == nil { + continue // Пропускаем nil-сообщения (heartbeats) + } + t.Logf("Received message %d: %s", received+1, msg.Content) + received++ + if received >= 5 { + return // Успешно получили все сообщения + } + case <-ctx.Done(): + lastErr = ctx.Err() + break + } + + // Выходим из цикла если была ошибка или завершение + if lastErr != nil { + break + } + } + + if received < 5 { + if lastErr != nil { + require.NoError(t, lastErr, "Didn't receive all messages") + } else { + t.Errorf("Expected 5 messages, received %d", received) + } + } +} + +func TestIntegration_GetChatMessages(t *testing.T) { + r := setupIntegrationTest(t) + ctx := context.Background() + + // Используем существующих пользователей + const user1ID = 5 + const user2ID = 12 + + // Создаем чат + mutation := &mutationResolver{r} + chat, err := mutation.CreateChat(ctx, user1ID, user2ID) + require.NoError(t, err, "Failed to create chat") + + // Отправляем ровно 5 сообщений + expectedContents := make([]string, 5) + for i := 0; i < 5; i++ { + content := fmt.Sprintf("test message %d", i+1) + _, err := mutation.SendMessage( + context.WithValue(ctx, userIDKey, user1ID), + int(chat.ID), + content, + ) + require.NoError(t, err, "Failed to send message") + expectedContents[i] = content + } + + // Даем время на обработку + time.Sleep(1 * time.Second) + + // Проверяем получение сообщений + query := &queryResolver{r} + messages, err := query.GetChatMessages(ctx, int(chat.ID), 10, 0) + require.NoError(t, err, "Failed to get chat messages") + + // Проверяем что получили хотя бы 5 сообщений + assert.GreaterOrEqual(t, len(messages), 5, "Expected at least 5 messages") + + // Проверяем последние 5 сообщений + for i := 0; i < 5; i++ { + assert.Equal(t, expectedContents[4-i], messages[i].Content, "Message content mismatch") + } +} + +func TestIntegration_GetUserChats(t *testing.T) { + r := setupIntegrationTest(t) + ctx := context.Background() + + // Создаем несколько чатов + mutation := &mutationResolver{r} + _, err := mutation.CreateChat(ctx, 1, 2) + require.NoError(t, err) + _, err = mutation.CreateChat(ctx, 1, 3) + require.NoError(t, err) + + // Проверяем получение чатов пользователя + query := &queryResolver{r} + chats, err := query.GetUserChats(ctx, 1) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(chats), 2) +} + +func TestIntegration_UpdateMessageStatus(t *testing.T) { + r := setupIntegrationTest(t) + ctx := context.Background() + + // Используем существующих пользователей + const user1ID = 5 + const user2ID = 12 + + // Создаем чат + mutation := &mutationResolver{r} + chat, err := mutation.CreateChat(ctx, user1ID, user2ID) + require.NoError(t, err, "Failed to create chat") + + // Отправляем тестовое сообщение + msg, err := mutation.SendMessage( + context.WithValue(ctx, userIDKey, user1ID), + int(chat.ID), + "status test", + ) + require.NoError(t, err, "Failed to send message") + + // Даем время на обработку + time.Sleep(500 * time.Millisecond) + + // Определяем поддерживаемые статусы из вашего сервера + // (должны соответствовать тому, что действительно принимает сервер) + validStatuses := []struct { + name string + status string + }{ + {"SENT", "SENT"}, + {"DELIVERED", "DELIVERED"}, + {"READ", "READ"}, + } + + for _, ts := range validStatuses { + t.Run(fmt.Sprintf("Update to %s", ts.name), func(t *testing.T) { + // Преобразуем строковый статус в MessageStatus + var msgStatus MessageStatus + switch ts.status { + case "SENT": + msgStatus = MessageStatusSent + case "DELIVERED": + msgStatus = MessageStatusDelivered + case "READ": + msgStatus = MessageStatusRead + default: + t.Fatalf("Unsupported status: %s", ts.status) + } + + updatedMsg, err := mutation.UpdateMessageStatus(ctx, msg.ID, msgStatus) + if err != nil { + t.Logf("If this fails, check your server's message status constraints") + } + require.NoError(t, err, "Failed to update message status") + assert.Equal(t, ts.status, updatedMsg.Status, "Status mismatch") + }) + } +} diff --git a/internal/http/handlers/chat.go b/internal/http/handlers/chat.go deleted file mode 100644 index 8857526..0000000 --- a/internal/http/handlers/chat.go +++ /dev/null @@ -1,224 +0,0 @@ -package handlers - -import ( - "context" - "log" - "net/http" - "strings" - "tailly_back_v2/internal/domain" - "tailly_back_v2/internal/service" - "tailly_back_v2/internal/ws" - "tailly_back_v2/pkg/auth" - "time" - - "github.com/gorilla/websocket" -) - -var upgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return true - }, - Subprotocols: []string{"graphql-transport-ws"}, -} - -type ChatHandler struct { - chatService service.ChatService - hub *ws.Hub - tokenAuth *auth.TokenAuth -} - -func NewChatHandler(chatService service.ChatService, hub *ws.Hub, tokenAuth *auth.TokenAuth) *ChatHandler { - return &ChatHandler{ - chatService: chatService, - hub: hub, - tokenAuth: tokenAuth, - } -} - -func (h *ChatHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) { - log.Printf("Incoming WebSocket headers: %+v", r.Header) - log.Printf("Cookies: %+v", r.Cookies()) - requestedProtocol := r.Header.Get("Sec-WebSocket-Protocol") - if requestedProtocol != "" && requestedProtocol != "graphql-transport-ws" { - http.Error(w, "Unsupported WebSocket protocol", http.StatusBadRequest) - return - } - log.Printf("Requested protocols: %v", r.Header["Sec-WebSocket-Protocol"]) - // 1. Проверяем куки - var token string - cookie, err := r.Cookie("accessToken") - if err == nil { - token = cookie.Value - log.Printf("WebSocket: токен из куки: %s", token) - } - - // Из заголовка Authorization - if authHeader := r.Header.Get("Authorization"); authHeader != "" { - token = strings.TrimPrefix(authHeader, "Bearer ") - } - - // Из параметра URL - if token == "" { - token = r.URL.Query().Get("token") - } - - // Из куков - if token == "" { - if cookie, err := r.Cookie("accessToken"); err == nil { - token = cookie.Value - } - } - - if token == "" { - log.Println("WebSocket: токен не найден") - http.Error(w, "Token is required", http.StatusUnauthorized) - return - } - - // Валидация токена - userID, err := h.tokenAuth.ValidateAccessToken(token) - if err != nil { - log.Printf("WebSocket: ошибка валидации токена: %v", err) - http.Error(w, "Invalid token", http.StatusUnauthorized) - return - } - - log.Printf("WebSocket: успешная авторизация, userID=%d", userID) - - // 5. Обновление соединения - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("WebSocket upgrade error: %v", err) - http.Error(w, "Could not upgrade to WebSocket", http.StatusBadRequest) - return - } - - client := &ws.Client{ - UserID: userID, - Send: make(chan *domain.Message, 256), - } - - h.hub.RegisterClient(client) - - // Добавляем контекст для управления жизненным циклом соединения - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Запускаем горутины с обработкой контекста - go h.readPump(ctx, conn, client, userID) - go h.writePump(ctx, conn, client) - - // Ждем завершения - <-ctx.Done() -} - -func (h *ChatHandler) readPump(ctx context.Context, conn *websocket.Conn, client *ws.Client, userID int) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // Отправляем ping - if err := conn.WriteJSON(map[string]string{"type": "ping"}); err != nil { - log.Printf("Ping error: %v", err) - return - } - case <-ctx.Done(): - return - default: - var msg struct { - Type string `json:"type"` - Payload struct { - ReceiverID int `json:"receiverId"` - Content string `json:"content"` - } `json:"payload"` - } - - if err := conn.ReadJSON(&msg); err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - log.Printf("WebSocket error: %v", err) - } - return - } - - if msg.Type == "pong" { - continue - } - - // Обработка ping/pong - if msg.Type == "ping" { - conn.WriteJSON(map[string]string{"type": "pong"}) - continue - } - - if msg.Type != "message" { - continue - } - - // Проверяем receiverId - if msg.Payload.ReceiverID == 0 { - log.Printf("Invalid receiverId: 0") - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "message": "Invalid receiver ID", - }) - continue - } - - // Логирование для отладки - log.Printf("Received message from %d to %d", userID, msg.Payload.ReceiverID) - - // Создаем или находим чат - chat, err := h.chatService.GetOrCreateChat(ctx, userID, msg.Payload.ReceiverID) - if err != nil { - log.Printf("Chat error: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "message": "Chat error", - "details": err.Error(), - }) - continue - } - - // Отправляем сообщение - message, err := h.chatService.SendMessage( - ctx, - userID, - chat.ID, - msg.Payload.Content, - ) - if err != nil { - log.Printf("Message send error: %v", err) - continue - } - - // Рассылаем сообщение - h.hub.Broadcast(message) - } - } -} - -func (h *ChatHandler) writePump(ctx context.Context, conn *websocket.Conn, client *ws.Client) { - defer conn.Close() - - for { - select { - case <-ctx.Done(): - return - case message, ok := <-client.Send: - if !ok { - // Канал закрыт - conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - - if err := conn.WriteJSON(message); err != nil { - log.Printf("WebSocket write error: %v", err) - return - } - } - } -} diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..9317905 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,29 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-go:2025.1