378 lines
15 KiB
Go
378 lines
15 KiB
Go
|
|
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
|
|||
|
|
}
|