package server import ( "bytes" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "os" "testing" "glchat/internal/gateway" ) // Тесты иконки групповой беседы (AGENT.md 7.8, D-070): загрузка владельцем, // отдача в списке бесед и READY, права владельца и событие DM_CHANNEL_UPDATE. // dmIconFixture — групповая беседа Алиса (владелец) + Боб + Кэрол и посторонний // Дейв: на нём проверяются запреты. type dmIconFixture struct { srv *Server owner, member, stranger *http.Cookie ownerID, memberID string strangerID string channelID string } func newDMIconFixture(t *testing.T) dmIconFixture { t.Helper() srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "icon_alice", "icon-alice@example.com") member := registerAndLogin(t, srv, "icon_bob", "icon-bob@example.com") registerAndLogin(t, srv, "icon_carol", "icon-carol@example.com") stranger := registerAndLogin(t, srv, "icon_dave", "icon-dave@example.com") userID := func(email string) string { t.Helper() user, err := srv.auth.UserByEmail(t.Context(), email) if err != nil { t.Fatalf("UserByEmail(%s): %v", email, err) } return formatSnowflake(user.ID) } bobID := userID("icon-bob@example.com") carolID := userID("icon-carol@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", `{"name":"С иконкой","recipient_ids":["`+bobID+`","`+carolID+`"]}`, owner) if created.Code != http.StatusOK { t.Fatalf("создание группы = %d, body = %s", created.Code, created.Body.String()) } channel := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, created) return dmIconFixture{ srv: srv, owner: owner, member: member, stranger: stranger, ownerID: userID("icon-alice@example.com"), memberID: bobID, strangerID: userID("icon-dave@example.com"), channelID: channel.Channel.ID, } } // postMultipartFile отправляет multipart-запрос с полем file на указанный путь. func postMultipartFile(t *testing.T, srv *Server, path, filename string, content []byte, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", filename) if err != nil { t.Fatalf("multipart: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("multipart write: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("multipart close: %v", err) } request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, path, &body) request.Header.Set("Content-Type", writer.FormDataContentType()) if cookie != nil { request.AddCookie(cookie) } rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, request) return rec } // uploadDMIcon отправляет multipart-запрос загрузки иконки беседы. func uploadDMIcon(t *testing.T, srv *Server, channelID, filename string, content []byte, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() return postMultipartFile(t, srv, "/api/v1/channels/"+channelID+"/icon", filename, content, cookie) } // channelIconField читает `icon_file_id` беседы из REST-списка: то же поле, // которое видит клиент в сайдбаре и шапке (D-070). func channelIconField(t *testing.T, srv *Server, cookie *http.Cookie, channelID string) string { t.Helper() rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", cookie) if rec.Code != http.StatusOK { t.Fatalf("GET /users/@me/channels = %d, body = %s", rec.Code, rec.Body.String()) } var payload struct { Channels []struct { ID string `json:"id"` IconFileID string `json:"icon_file_id"` } `json:"channels"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode channels: %v (raw: %s)", err, rec.Body.String()) } for _, channel := range payload.Channels { if channel.ID == channelID { return channel.IconFileID } } t.Fatalf("беседа %s не найдена в списке", channelID) return "" } // TestDMChannelIconLifecycle — загрузка иконки владельцем: ответ, список бесед // у всех участников, READY, событие DM_CHANNEL_UPDATE и снятие иконки. func TestDMChannelIconLifecycle(t *testing.T) { fixture := newDMIconFixture(t) srv := fixture.srv // Событие об изменении иконки должно доехать до остальных участников без // перезагрузки (AGENT.md 11.6, D-070). httpServer := httptest.NewServer(srv.Handler()) t.Cleanup(httpServer.Close) bob := dialGateway(t, httpServer, fixture.member) rec := uploadDMIcon(t, srv, fixture.channelID, "icon.png", pngBytes(), fixture.owner) if rec.Code != http.StatusOK { t.Fatalf("загрузка иконки владельцем = %d, body = %s", rec.Code, rec.Body.String()) } uploaded := decodeResponse[struct { Channel struct { ID string `json:"id"` IsGroup bool `json:"is_group"` IconFileID string `json:"icon_file_id"` OwnerID string `json:"owner_id"` } `json:"channel"` }](t, rec) if uploaded.Channel.IconFileID == "" { t.Fatalf("в ответе нет icon_file_id: %s", rec.Body.String()) } if uploaded.Channel.ID != fixture.channelID || !uploaded.Channel.IsGroup || uploaded.Channel.OwnerID != fixture.ownerID { t.Fatalf("неожиданная карточка беседы: %s", rec.Body.String()) } iconID := uploaded.Channel.IconFileID fileID, err := parseID("file_id", iconID) if err != nil { t.Fatalf("parseID(icon): %v", err) } // Файл — обычная загрузка с назначением dm_icon (D-042). file, err := srv.store.GetFile(t.Context(), fileID) if err != nil { t.Fatalf("файл иконки не найден: %v", err) } if file.Purpose != "dm_icon" { t.Fatalf("purpose = %q, ожидался dm_icon", file.Purpose) } if _, err := os.Stat(file.StoragePath); err != nil { t.Fatalf("содержимое иконки не сохранено: %v", err) } // Иконка видна каждому участнику, постороннему беседа не видна вовсе. for _, cookie := range []*http.Cookie{fixture.owner, fixture.member} { if icon := channelIconField(t, srv, cookie, fixture.channelID); icon != iconID { t.Fatalf("иконка в списке = %q, ожидалась %q", icon, iconID) } } for _, channel := range listDMChannels(t, srv, fixture.stranger).Channels { if channel.ID == fixture.channelID { t.Fatal("посторонний видит чужую групповую беседу") } } // READY отдаёт иконку группы тем же полем (AGENT.md 8.3, 11.6): после // перезагрузки страницы иконка не должна пропадать. owner, err := srv.auth.UserByEmail(t.Context(), "icon-alice@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } builder := gateway.NewSnapshot(srv.store, srv.perms) snapshot, err := builder.Build(t.Context(), owner) if err != nil { t.Fatalf("snapshot: %v", err) } payload, err := json.Marshal(snapshot) if err != nil { t.Fatalf("marshal snapshot: %v", err) } var ready struct { DMChannels []struct { ID string `json:"id"` IsGroup bool `json:"is_group"` IconFileID string `json:"icon_file_id"` } `json:"dm_channels"` } if err := json.Unmarshal(payload, &ready); err != nil { t.Fatalf("decode snapshot: %v", err) } found := false for _, channel := range ready.DMChannels { if channel.ID == fixture.channelID { found = true if !channel.IsGroup || channel.IconFileID != iconID { t.Fatalf("иконка группы в READY = %+v", channel) } } } if !found { t.Fatalf("группа не попала в READY: %s", payload) } // Участник получает событие обновления беседы. event := bob.expectEvent("DM_CHANNEL_UPDATE") var updated struct { ChannelID string `json:"channel_id"` } if err := json.Unmarshal(event.D, &updated); err != nil { t.Fatalf("decode DM_CHANNEL_UPDATE: %v", err) } if updated.ChannelID != fixture.channelID { t.Fatalf("channel_id в событии = %q", updated.ChannelID) } // Снятие иконки владельцем: поле очищается, файл удаляется, событие уходит. rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+fixture.channelID+"/icon", "", fixture.owner) if rec.Code != http.StatusOK { t.Fatalf("снятие иконки = %d, body = %s", rec.Code, rec.Body.String()) } cleared := decodeResponse[struct { Channel struct { IconFileID string `json:"icon_file_id"` } `json:"channel"` }](t, rec) if cleared.Channel.IconFileID != "" { t.Fatalf("после снятия icon_file_id = %q", cleared.Channel.IconFileID) } if _, err := srv.store.GetFile(t.Context(), fileID); err == nil { t.Fatal("после снятия иконки файл остался в базе") } if _, err := os.Stat(file.StoragePath); !os.IsNotExist(err) { t.Fatalf("после снятия иконки файл остался на диске: %v", err) } if icon := channelIconField(t, srv, fixture.member, fixture.channelID); icon != "" { t.Fatalf("иконка осталась в списке у участника: %q", icon) } bob.expectEvent("DM_CHANNEL_UPDATE") } // TestDMChannelIconPermissions — иконку меняет только владелец групповой // беседы; у 1:1 своей иконки нет (AGENT.md 7.8, D-070). func TestDMChannelIconPermissions(t *testing.T) { fixture := newDMIconFixture(t) srv := fixture.srv // Участник-не-владелец получает 403. rec := uploadDMIcon(t, srv, fixture.channelID, "icon.png", pngBytes(), fixture.member) if rec.Code != http.StatusForbidden { t.Fatalf("загрузка участником = %d, ожидалось 403, body = %s", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "perm.denied" { t.Fatalf("код ошибки = %q", code) } rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+fixture.channelID+"/icon", "", fixture.member) if rec.Code != http.StatusForbidden { t.Fatalf("снятие участником = %d, ожидалось 403", rec.Code) } // Посторонний не должен даже узнать о существовании беседы: 404. rec = uploadDMIcon(t, srv, fixture.channelID, "icon.png", pngBytes(), fixture.stranger) if rec.Code != http.StatusNotFound { t.Fatalf("загрузка посторонним = %d, ожидалось 404, body = %s", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "not_found" { t.Fatalf("код ошибки = %q", code) } rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+fixture.channelID+"/icon", "", fixture.stranger) if rec.Code != http.StatusNotFound { t.Fatalf("снятие посторонним = %d, ожидалось 404", rec.Code) } // Беседа 1:1: иконка — аватар собеседника, своей у неё нет. opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+fixture.strangerID+`"}`, fixture.owner) if opened.Code != http.StatusOK { t.Fatalf("открытие 1:1 = %d, body = %s", opened.Code, opened.Body.String()) } direct := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, opened) rec = uploadDMIcon(t, srv, direct.Channel.ID, "icon.png", pngBytes(), fixture.owner) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("загрузка иконки в 1:1 = %d, ожидалось 422, body = %s", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "dm.not_group" { t.Fatalf("код ошибки = %q", code) } rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+direct.Channel.ID+"/icon", "", fixture.owner) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("снятие иконки в 1:1 = %d, ожидалось 422", rec.Code) } // Посторонний в 1:1 тоже получает 404, а не 422: существование не подтверждаем. rec = doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+direct.Channel.ID+"/icon", "", fixture.member) if rec.Code != http.StatusNotFound { t.Fatalf("снятие иконки посторонним в 1:1 = %d, ожидалось 404", rec.Code) } // Комната сервера — не беседа: иконка беседы к ней не применяется. created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Иконки"}`, fixture.owner) guild := decodeResponse[struct { Guild struct { ID string `json:"id"` } `json:"guild"` }](t, created) channel := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", `{"name":"общая","type":"text"}`, fixture.owner) guildChannel := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, channel) rec = uploadDMIcon(t, srv, guildChannel.Channel.ID, "icon.png", pngBytes(), fixture.owner) if rec.Code != http.StatusNotFound { t.Fatalf("загрузка иконки в комнату сервера = %d, ожидалось 404", rec.Code) } } // TestDMChannelIconValidation — иконка принимается только картинкой в пределах // аватарного лимита (AGENT.md 7.7, 9.2). func TestDMChannelIconValidation(t *testing.T) { fixture := newDMIconFixture(t) srv := fixture.srv // Не картинка: переименованный текстовый файл отклоняется. rec := uploadDMIcon(t, srv, fixture.channelID, "icon.txt", []byte("это не картинка"), fixture.owner) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("не-картинка = %d, ожидалось 422, body = %s", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "validation.failed" { t.Fatalf("код ошибки = %q", code) } // Поле file обязательно. var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("other", "значение"); err != nil { t.Fatalf("multipart: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("multipart close: %v", err) } request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/channels/"+fixture.channelID+"/icon", &body) request.Header.Set("Content-Type", writer.FormDataContentType()) request.AddCookie(fixture.owner) missing := httptest.NewRecorder() srv.Handler().ServeHTTP(missing, request) if missing.Code != http.StatusBadRequest { t.Fatalf("без поля file = %d, ожидалось 400, body = %s", missing.Code, missing.Body.String()) } // Аватарный лимит: файл сверх него — 413 (AGENT.md 7.7). if err := srv.store.SetInstanceSetting(t.Context(), "max_avatar_size", "1024"); err != nil { t.Fatalf("SetInstanceSetting: %v", err) } large := append(pngBytes(), make([]byte, 2048)...) rec = uploadDMIcon(t, srv, fixture.channelID, "big.png", large, fixture.owner) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("файл сверх лимита = %d, ожидалось 413, body = %s", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "file.too_large" { t.Fatalf("код ошибки = %q", code) } } // TestDMChannelIconPlacedInReady — в READY у 1:1 в icon_file_id приходит аватар // собеседника, а у группы — своя иконка (AGENT.md 7.8, 8.3, D-070). func TestDMChannelIconPlacedInReady(t *testing.T) { fixture := newDMIconFixture(t) srv := fixture.srv // Боб загружает аватар, Алиса открывает с ним 1:1. avatar := postMultipartFile(t, srv, "/api/v1/users/@me/avatar", "bob.png", pngBytes(), fixture.member) if avatar.Code != http.StatusOK { t.Fatalf("загрузка аватара = %d, body = %s", avatar.Code, avatar.Body.String()) } profile := decodeResponse[struct { User struct { AvatarFileID string `json:"avatar_file_id"` } `json:"user"` }](t, avatar) if profile.User.AvatarFileID == "" { t.Fatalf("аватар не сохранился: %s", avatar.Body.String()) } opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+fixture.memberID+`"}`, fixture.owner) direct := decodeResponse[struct { Channel struct { ID string `json:"id"` IconFileID string `json:"icon_file_id"` Recipient struct { AvatarFileID string `json:"avatar_file_id"` } `json:"recipient"` } `json:"channel"` }](t, opened) if direct.Channel.IconFileID != profile.User.AvatarFileID || direct.Channel.Recipient.AvatarFileID != profile.User.AvatarFileID { t.Fatalf("иконка 1:1 = %+v, аватар = %q", direct.Channel, profile.User.AvatarFileID) } // Группа без иконки: поле пустое, но у 1:1 оно заполнено — клиент рисует // иконку группы из своего поля (D-070). if icon := channelIconField(t, srv, fixture.owner, fixture.channelID); icon != "" { t.Fatalf("у новой группы появилась иконка: %q", icon) } }