package server import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "testing" "time" ) // TestFriendRequestFlow проверяет заявки в друзья и поиск пользователей. func TestFriendRequestFlow(t *testing.T) { srv, _ := newTestServer(t) aliceCookie := registerAndLogin(t, srv, "alice_friend", "alice-friend@example.com") bobCookie := registerAndLogin(t, srv, "bob_friend", "bob-friend@example.com") bob, err := srv.auth.UserByEmail(t.Context(), "bob-friend@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } // Поиск по логину находит Боба и показывает состояние связи. search := doJSON(t, srv, http.MethodGet, "/api/v1/users/search?q=bob_fr", "", aliceCookie) if search.Code != http.StatusOK { t.Fatalf("search = %d, body = %s", search.Code, search.Body.String()) } found := decodeResponse[struct { Users []struct { Username string `json:"username"` Relationship string `json:"relationship"` } `json:"users"` }](t, search) if len(found.Users) != 1 || found.Users[0].Username != "bob_friend" || found.Users[0].Relationship != "none" { t.Fatalf("search results = %+v", found.Users) } // Алиса отправляет заявку: у неё outgoing, у Боба incoming. sent := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_friend"}`, aliceCookie) if sent.Code != http.StatusOK { t.Fatalf("send request = %d, body = %s", sent.Code, sent.Body.String()) } bobList := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", bobCookie) bobPayload := decodeResponse[struct { Incoming []struct { Username string `json:"username"` Relationship string `json:"relationship"` } `json:"incoming"` Friends []struct { Username string `json:"username"` } `json:"friends"` }](t, bobList) if len(bobPayload.Incoming) != 1 || bobPayload.Incoming[0].Username != "alice_friend" { t.Fatalf("incoming = %+v", bobPayload.Incoming) } if len(bobPayload.Friends) != 0 { t.Fatalf("friends must be empty before accept: %+v", bobPayload.Friends) } // Боб принимает: оба видят друг друга в друзьях. accept := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-friend@example.com"))+"/accept", "", bobCookie) if accept.Code != http.StatusOK { t.Fatalf("accept = %d, body = %s", accept.Code, accept.Body.String()) } aliceFriends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie) payload := decodeResponse[struct { Friends []struct { Username string `json:"username"` VisibleStatus string `json:"visible_status"` Timezone string `json:"timezone"` } `json:"friends"` }](t, aliceFriends) if len(payload.Friends) != 1 || payload.Friends[0].Username != "bob_friend" { t.Fatalf("friends = %+v", payload.Friends) } if payload.Friends[0].Timezone != "Europe/Moscow" { t.Fatalf("default timezone = %q, want Europe/Moscow", payload.Friends[0].Timezone) } // Повторная заявка ничего не ломает. repeat := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"user_id":"`+formatSnowflake(bob.ID)+`"}`, aliceCookie) if repeat.Code != http.StatusOK { t.Fatalf("repeat request = %d, body = %s", repeat.Code, repeat.Body.String()) } } // TestDirectChannelMessaging проверяет личные беседы: доступ только участникам. func TestDirectChannelMessaging(t *testing.T) { srv, _ := newTestServer(t) aliceCookie := registerAndLogin(t, srv, "alice_dm", "alice-dm@example.com") bobCookie := registerAndLogin(t, srv, "bob_dm", "bob-dm@example.com") strangerCookie := registerAndLogin(t, srv, "eve_dm", "eve-dm@example.com") bobID := formatSnowflake(mustUserID(t, srv, "bob-dm@example.com")) // Алиса открывает беседу с Бобом. opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+bobID+`"}`, aliceCookie) if opened.Code != http.StatusOK { t.Fatalf("open dm = %d, body = %s", opened.Code, opened.Body.String()) } channel := decodeResponse[struct { Channel struct { ID string `json:"id"` Type string `json:"type"` CanSend bool `json:"can_send"` Recipient struct { UserID string `json:"user_id"` DisplayName string `json:"display_name"` VisibleStatus string `json:"visible_status"` } `json:"recipient"` } `json:"channel"` }](t, opened) if channel.Channel.Type != "dm" || !channel.Channel.CanSend { t.Fatalf("unexpected dm channel: %+v", channel.Channel) } if channel.Channel.Recipient.UserID != bobID { t.Fatalf("recipient = %q, want %q", channel.Channel.Recipient.UserID, bobID) } // Повторное открытие возвращает ту же беседу. again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+bobID+`"}`, aliceCookie) reopened := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, again) if reopened.Channel.ID != channel.Channel.ID { t.Fatalf("dm channel changed: %s → %s", channel.Channel.ID, reopened.Channel.ID) } // Сообщение из беседы и список бесед у обоих участников. sent := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages", `{"content":"привет в личке"}`, aliceCookie) if sent.Code != http.StatusOK { t.Fatalf("dm message = %d, body = %s", sent.Code, sent.Body.String()) } list := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", bobCookie) channels := decodeResponse[struct { Channels []struct { ID string `json:"id"` LastMessageID string `json:"last_message_id"` } `json:"channels"` }](t, list) if len(channels.Channels) != 1 || channels.Channels[0].ID != channel.Channel.ID || channels.Channels[0].LastMessageID == "" { t.Fatalf("dm list = %+v", channels.Channels) } // Посторонний не видит беседу и не может писать. forbidden := doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channel.Channel.ID+"/messages", "", strangerCookie) if forbidden.Code != http.StatusNotFound { t.Fatalf("stranger dm read = %d, want 404", forbidden.Code) } forbiddenSend := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages", `{"content":"я тут лишний"}`, strangerCookie) if forbiddenSend.Code != http.StatusNotFound { t.Fatalf("stranger dm write = %d, want 404", forbiddenSend.Code) } } // TestInvisibleStatusHiddenFromFriends проверяет, что «невидимка» виден как офлайн. func TestInvisibleStatusHiddenFromFriends(t *testing.T) { srv, _ := newTestServer(t) aliceCookie := registerAndLogin(t, srv, "alice_inv", "alice-inv@example.com") bobCookie := registerAndLogin(t, srv, "bob_inv", "bob-inv@example.com") doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_inv"}`, aliceCookie) accept := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-inv@example.com"))+"/accept", "", bobCookie) if accept.Code != http.StatusOK { t.Fatalf("accept = %d", accept.Code) } // Боб уходит в невидимку. hidden := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"invisible"}`, bobCookie) if hidden.Code != http.StatusOK { t.Fatalf("set invisible = %d, body = %s", hidden.Code, hidden.Body.String()) } friends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie) payload := decodeResponse[struct { Friends []struct { Username string `json:"username"` Status string `json:"status"` VisibleStatus string `json:"visible_status"` } `json:"friends"` }](t, friends) if len(payload.Friends) != 1 { t.Fatalf("friends = %+v", payload.Friends) } if payload.Friends[0].VisibleStatus != "offline" { t.Fatalf("invisible user must look offline, got %q", payload.Friends[0].VisibleStatus) } // Обычный статус виден как есть. doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"idle"}`, bobCookie) friends = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie) payload = decodeResponse[struct { Friends []struct { Username string `json:"username"` Status string `json:"status"` VisibleStatus string `json:"visible_status"` } `json:"friends"` }](t, friends) if payload.Friends[0].VisibleStatus != "idle" { t.Fatalf("idle status must be visible, got %q", payload.Friends[0].VisibleStatus) } } // TestTimezoneAndAvatarUpdate проверяет часовой пояс и загрузку аватара. func TestTimezoneAndAvatarUpdate(t *testing.T) { srv, _ := newTestServer(t) httpServer := httptest.NewServer(srv.Handler()) t.Cleanup(httpServer.Close) cookie := registerAndLogin(t, srv, "tz_user", "tz-user@example.com") // Часовой пояс по умолчанию — МСК, можно сменить. type mePayload struct { User struct { Timezone string `json:"timezone"` AvatarFileID string `json:"avatar_file_id"` } `json:"user"` } me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) profile := decodeResponse[mePayload](t, me) if profile.User.Timezone != "Europe/Moscow" { t.Fatalf("default timezone = %q", profile.User.Timezone) } updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"timezone":"Europe/Berlin"}`, cookie) if updated.Code != http.StatusOK { t.Fatalf("set timezone = %d, body = %s", updated.Code, updated.Body.String()) } after := decodeResponse[mePayload](t, updated) if after.User.Timezone != "Europe/Berlin" { t.Fatalf("timezone = %q, want Europe/Berlin", after.User.Timezone) } bad := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"timezone":"Марс/Олимп"}`, cookie) if bad.Code != http.StatusUnprocessableEntity { t.Fatalf("invalid timezone = %d, want 422", bad.Code) } // Аватар: загрузка картинки и удаление. fileID := uploadAvatar(t, httpServer, cookie, "me.png", append([]byte("\x89PNG\r\n\x1a\n"), []byte("аватар-данные")...)) if fileID == "" { t.Fatal("avatar upload must return a file id") } me = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) profile = decodeResponse[mePayload](t, me) if profile.User.AvatarFileID != fileID { t.Fatalf("avatar_file_id = %q, want %q", profile.User.AvatarFileID, fileID) } // Аватар виден другому авторизованному пользователю (он показывается в // списках друзей и участников), но не анонимному. otherCookie := registerAndLogin(t, srv, "tz_other", "tz-other@example.com") _ = otherCookie downloadReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, httpServer.URL+"/files/"+fileID, nil) if err != nil { t.Fatalf("new request: %v", err) } downloadReq.AddCookie(otherCookie) downloadResp, err := httpServer.Client().Do(downloadReq) if err != nil { t.Fatalf("avatar download: %v", err) } _ = downloadResp.Body.Close() if downloadResp.StatusCode != http.StatusOK { t.Fatalf("другой пользователь не видит аватар: %d", downloadResp.StatusCode) } removed := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/avatar", "", cookie) if removed.Code != http.StatusOK { t.Fatalf("delete avatar = %d, body = %s", removed.Code, removed.Body.String()) } me = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) profile = decodeResponse[mePayload](t, me) if profile.User.AvatarFileID != "" { t.Fatalf("avatar must be cleared, got %q", profile.User.AvatarFileID) } } // mustUserID находит пользователя по email и возвращает его идентификатор. func mustUserID(t *testing.T, srv *Server, email string) uint64 { t.Helper() user, err := srv.auth.UserByEmail(t.Context(), email) if err != nil { t.Fatalf("UserByEmail(%s): %v", email, err) } return user.ID } // TestPresenceUpdateReachesFriends проверяет, что смена статуса видна другу // через Gateway без перезагрузки (AGENT.md 7.16). func TestPresenceUpdateReachesFriends(t *testing.T) { srv, _ := newTestServer(t) httpServer := httptest.NewServer(srv.Handler()) t.Cleanup(httpServer.Close) aliceCookie := registerAndLogin(t, srv, "alice_pres", "alice-pres@example.com") bobCookie := registerAndLogin(t, srv, "bob_pres", "bob-pres@example.com") doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_pres"}`, aliceCookie) doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-pres@example.com"))+"/accept", "", bobCookie) alice := dialGateway(t, httpServer, aliceCookie) doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"dnd"}`, bobCookie) frame := alice.expectEvent("PRESENCE_UPDATE") var payload struct { Status string `json:"status"` VisibleStatus string `json:"visible_status"` } if err := json.Unmarshal(frame.D, &payload); err != nil { t.Fatalf("decode PRESENCE_UPDATE: %v", err) } if payload.Status != "dnd" || payload.VisibleStatus != "dnd" { t.Fatalf("presence payload = %+v", payload) } } // TestDirectChannelEventsOnlyForParticipants проверяет фильтрацию событий DM. func TestDirectChannelEventsOnlyForParticipants(t *testing.T) { srv, _ := newTestServer(t) httpServer := httptest.NewServer(srv.Handler()) t.Cleanup(httpServer.Close) aliceCookie := registerAndLogin(t, srv, "alice_dmev", "alice-dmev@example.com") bobCookie := registerAndLogin(t, srv, "bob_dmev", "bob-dmev@example.com") eveCookie := registerAndLogin(t, srv, "eve_dmev", "eve-dmev@example.com") bobID := formatSnowflake(mustUserID(t, srv, "bob-dmev@example.com")) opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+bobID+`"}`, aliceCookie) channel := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, opened) bob := dialGateway(t, httpServer, bobCookie) eve := dialGateway(t, httpServer, eveCookie) doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages", `{"content":"личное сообщение"}`, aliceCookie) bob.expectEvent("MESSAGE_CREATE") eve.expectNoEvent("MESSAGE_CREATE", 700*time.Millisecond) } // uploadAvatar загружает аватар через multipart и возвращает file_id. func uploadAvatar(t *testing.T, server *httptest.Server, cookie *http.Cookie, filename string, content []byte) string { t.Helper() body := &bytes.Buffer{} writer := multipart.NewWriter(body) partHeader := textproto.MIMEHeader{} partHeader.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`) partHeader.Set("Content-Type", "image/png") part, err := writer.CreatePart(partHeader) if err != nil { t.Fatalf("CreatePart: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write avatar: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close writer: %v", err) } req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/api/v1/users/@me/avatar", bytes.NewReader(body.Bytes())) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(cookie) resp, err := server.Client().Do(req) if err != nil { t.Fatalf("upload avatar: %v", err) } payload, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("upload avatar = %d, body = %s", resp.StatusCode, payload) } var decoded struct { User struct { AvatarFileID string `json:"avatar_file_id"` } `json:"user"` } if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode avatar response: %v", err) } return decoded.User.AvatarFileID }