Files
glchat/internal/server/social_test.go
T
grendervill 33dc1fec96 feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс
По запросу пользователя (вне очереди AGENT.md §13):

- главного сервера как точки входа больше нет: новичок начинает с пустым
  списком серверов, вход — только по приглашению или созданием своего;
- друзья: поиск по логину с экранированием LIKE, заявки (POST
  /users/@me/relationships), принятие, удаление/отклонение, списки friends/
  incoming/outgoing/blocked;
- личные беседы: POST /users/@me/channels (идемпотентно), GET
  /users/@me/channels со собеседником, статусом и последним сообщением;
  сообщения в DM работают через общие ручки комнат, доступ — только участникам
  (посторонний получает 404, события в Gateway тоже фильтруются);
- присутствие: last_seen_at обновляется при активности, «невидимка» и простой
  дольше двух минут выглядят как офлайн, смена статуса рассылает
  PRESENCE_UPDATE друзьям;
- READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение);
- профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me,
  загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том
  числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл
  удаляется с диска;
- тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой
  пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
2026-09-20 01:11:04 +03:00

378 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
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
}