feat(files): загрузка и выдача вложений

- POST /api/v1/channels/{id}/files (multipart, ATTACH_FILES): файл пишется на
  диск под идентификатором, в БД хранятся метаданные и sha256; имя файла
  очищается от путей и управляющих символов, объём ограничен MAX_UPLOAD_SIZE;
- GET/HEAD /files/{id}: содержимое с проверкой прав на комнату сообщения
  (невидимая комната и чужие вложения → 404), ETag, immutable-кэш,
  Content-Disposition с RFC 5987 для не-ASCII имён;
- вложения привязываются к сообщению при отправке (attachment_ids), сироты
  ищутся через store.ListOrphanFiles для обслуживания;
- GET /api/v1/files/{id} — метаданные файла для клиента;
- chi-ручки теперь умеют отдавать huma-ошибки в общем конверте
  (writeHumaAPIError), иначе 404 превращался в 500;
- READY отдаёт реальные read states пользователя.

Тесты: загрузка, скачивание участником, скрытая комната → 404, вложение в
сообщении, проверка подписи ETag.
This commit is contained in:
2026-09-19 23:36:16 +03:00
parent 63bf1e63b5
commit 0e39b8e027
25 changed files with 1162 additions and 39 deletions
+14
View File
@@ -62,6 +62,20 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
ready.User.AvatarFileID = formatID(*user.AvatarFileID) ready.User.AvatarFileID = formatID(*user.AvatarFileID)
} }
// Состояния прочтения: непрочитанные и упоминания синхронизируются между
// устройствами пользователя (AGENT.md 7.16).
readStates, err := s.store.ListReadStates(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("list read states: %w", err)
}
for _, state := range readStates {
ready.ReadStates = append(ready.ReadStates, ReadyRead{
ChannelID: formatID(state.ChannelID),
LastMessageID: formatID(state.LastMessageID),
MentionCount: state.MentionCount,
})
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID) guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("list user guilds: %w", err) return nil, fmt.Errorf("list user guilds: %w", err)
+318
View File
@@ -0,0 +1,318 @@
package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/permissions"
"glchat/internal/store"
)
const (
// maxMultipartOverhead — запас на служебные поля multipart поверх лимита файла.
maxMultipartOverhead = 1 << 20
// maxMultipartMemory — сколько multipart держим в памяти, остальное — на диске.
maxMultipartMemory = 8 << 20
)
// uploadPayload — результат загрузки файла: метаданные для вложения.
type uploadPayload struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
URL string `json:"url"`
}
type uploadOutput struct {
Body struct {
File uploadPayload `json:"file"`
}
}
// registerFileRoutes описывает метаданные файла (huma) и загрузку/выдачу
// содержимого (chi: multipart и бинарный ответ вне контракта JSON-API, AGENT.md 8.2).
func (s *Server) registerFileRoutes(api huma.API, router chi.Router) {
huma.Register(api, huma.Operation{
OperationID: "getFileMeta",
Method: http.MethodGet,
Path: "/files/{file_id}",
Summary: "Метаданные файла",
Tags: []string{"Files"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
FileID string `path:"file_id"`
},
) (*uploadOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
file, err := s.requireFileAccess(ctx, input.FileID, user)
if err != nil {
return nil, err
}
output := &uploadOutput{}
output.Body.File = s.uploadPayload(file)
return output, nil
})
router.Post("/channels/{channel_id}/files", s.handleFileUpload)
}
// registerFileDownload вешает выдачу содержимого файла на корневой роутер:
// ссылки ведут на files.<domain>/files/{id} (AGENT.md 7.7).
func (s *Server) registerFileDownload(router chi.Router) {
router.Get("/files/{file_id}", s.handleFileDownload)
router.Head("/files/{file_id}", s.handleFileDownload)
}
// handleFileUpload принимает файл в комнату (AGENT.md 7.7).
func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
currentUser, _, ok := s.authenticate(w, r)
if !ok {
// authenticate уже отдал ошибку в конверте API.
return
}
ctx := r.Context()
channelID, _, channel, err := s.requireChannelPermission(ctx, chi.URLParam(r, "channel_id"), currentUser, permissions.AttachFiles)
if err != nil {
writeHumaAPIError(w, err)
return
}
// Тело ограничено лимитом файла плюс служебные поля multipart: разбор
// ограничен и по объёму (MaxBytesReader), и по памяти (maxMemory).
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadSize+maxMultipartOverhead)
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader выше
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
return
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
file, header, err := r.FormFile("file")
if err != nil {
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
return
}
defer func() { _ = file.Close() }()
if header.Size > s.cfg.MaxUploadSize {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large",
fmt.Sprintf("file exceeds %d bytes", s.cfg.MaxUploadSize))
return
}
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &currentUser.ID,
GuildID: channel.GuildID,
ChannelID: &channelID,
Filename: sanitizeFilename(header.Filename),
ContentType: header.Header.Get("Content-Type"),
}, file)
if err != nil {
writeHumaAPIError(w, err)
return
}
httpxWriteJSON(w, http.StatusOK, map[string]any{"file": s.uploadPayload(stored)})
}
// handleFileDownload отдаёт содержимое файла с проверкой прав на комнату.
func (s *Server) handleFileDownload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
writeAPIError(w, auth.ErrSessionExpired)
return
}
file, err := s.requireFileAccess(r.Context(), chi.URLParam(r, "file_id"), user)
if err != nil {
writeHumaAPIError(w, err)
return
}
handle, err := os.Open(file.StoragePath)
if err != nil {
s.logger.ErrorContext(r.Context(), "file is missing on disk",
slog.String("file_id", formatSnowflake(file.ID)))
writeAPIError(w, store.ErrNotFound)
return
}
defer func() { _ = handle.Close() }()
if file.ContentType != "" {
w.Header().Set("Content-Type", file.ContentType)
}
// Содержимое неизменяемо: адресуется идентификатором, поэтому кэшируем надолго.
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("ETag", `"`+file.SHA256+`"`)
w.Header().Set("Content-Disposition", contentDisposition(file.Filename))
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if _, err := io.Copy(w, handle); err != nil {
s.logger.DebugContext(r.Context(), "file download interrupted", slog.Any("error", err))
}
}
// requireFileAccess проверяет, что пользователь вправе видеть файл: он должен
// видеть комнату, к которой файл привязан (AGENT.md 7.7, 9.7).
func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *store.User) (*store.File, error) {
fileID, err := parseID("file_id", rawFileID)
if err != nil {
return nil, err
}
file, err := s.store.GetFile(ctx, fileID)
if err != nil {
return nil, humaError(err)
}
if file.ChannelID != nil {
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
return nil, err
}
return file, nil
}
// Файл ещё не привязан к сообщению: доступен только загрузившему.
if file.UploaderID == nil || *file.UploaderID != user.ID {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "file not found")
}
return file, nil
}
// saveUpload пишет файл на диск и регистрирует его в БД.
func (s *Server) saveUpload(ctx context.Context, file store.File, content io.Reader) (*store.File, error) {
directory := filepath.Join(s.cfg.DataDir, "files")
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot prepare storage")
}
// Идентификатор известен заранее: имя файла на диске не зависит от того,
// что прислал клиент (AGENT.md 9.2).
id := s.store.NextID()
path := filepath.Join(directory, strconv.FormatUint(id, 10))
// path собирается из идентификатора и каталога данных: путь клиента сюда
// не попадает (AGENT.md 9.2).
handle, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // путь из идентификатора
if err != nil {
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot store file")
}
hasher := sha256.New()
written, copyErr := io.Copy(io.MultiWriter(handle, hasher), io.LimitReader(content, s.cfg.MaxUploadSize+1))
closeErr := handle.Close()
if copyErr != nil || closeErr != nil {
_ = os.Remove(path)
return nil, humaErrorStatus(http.StatusInternalServerError, "internal.error", "cannot store file")
}
if written > s.cfg.MaxUploadSize {
_ = os.Remove(path)
return nil, humaErrorStatus(http.StatusRequestEntityTooLarge, "file.too_large", "file is too large")
}
if file.ContentType == "" {
file.ContentType = "application/octet-stream"
}
stored, err := s.store.CreateFile(ctx, store.CreateFileParams{
ID: id,
UploaderID: *file.UploaderID,
GuildID: file.GuildID,
ChannelID: file.ChannelID,
Filename: file.Filename,
ContentType: file.ContentType,
SizeBytes: written,
StoragePath: path,
SHA256: hex.EncodeToString(hasher.Sum(nil)),
})
if err != nil {
_ = os.Remove(path)
return nil, humaError(err)
}
return stored, nil
}
// uploadPayload собирает метаданные файла для API.
func (s *Server) uploadPayload(file *store.File) uploadPayload {
return uploadPayload{
FileID: formatSnowflake(file.ID),
Filename: file.Filename,
ContentType: file.ContentType,
SizeBytes: file.SizeBytes,
URL: s.cfg.FilesURL() + "/" + formatSnowflake(file.ID),
}
}
// sanitizeFilename убирает пути и управляющие символы из имени файла.
func sanitizeFilename(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
if index := strings.LastIndex(name, "/"); index >= 0 {
name = name[index+1:]
}
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
name = strings.TrimSpace(name)
if name == "" {
name = "file"
}
if runes := []rune(name); len(runes) > 200 {
name = string(runes[:200])
}
return name
}
// contentDisposition отдаёт безопасный заголовок для скачивания.
func contentDisposition(filename string) string {
ascii := strings.Map(func(r rune) rune {
if r > 0x7f || r == '"' || r == '\\' {
return '_'
}
return r
}, filename)
return `attachment; filename="` + ascii + `"; filename*=UTF-8''` + urlEncode(filename)
}
func urlEncode(value string) string {
var builder strings.Builder
for _, b := range []byte(value) {
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') ||
b == '-' || b == '_' || b == '.' || b == '~' {
builder.WriteByte(b)
continue
}
fmt.Fprintf(&builder, "%%%02X", b)
}
return builder.String()
}
// httpxWriteJSON пишет JSON-ответ из chi-ручки.
func httpxWriteJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
slog.Default().Debug("write json response", slog.Any("error", err))
}
}
// httpxWriteJSONError пишет ошибку в конверте API из chi-ручки.
func httpxWriteJSONError(w http.ResponseWriter, status int, code, message string) {
httpxWriteJSON(w, status, map[string]any{"error": map[string]any{"code": code, "message": message}})
}
+13
View File
@@ -81,6 +81,19 @@ func writeAPIError(w http.ResponseWriter, err error) {
}) })
} }
// writeHumaAPIError отдаёт ошибку chi-ручки: huma-ошибки уже несут код и
// статус, доменные ошибки переводит writeAPIError (AGENT.md 8.5).
func writeHumaAPIError(w http.ResponseWriter, err error) {
var apiErr *humaAPIError
if errors.As(err, &apiErr) {
httpx.WriteJSON(w, apiErr.status, map[string]any{
"error": map[string]any{"code": apiErr.code, "message": apiErr.message},
})
return
}
writeAPIError(w, err)
}
// writeJSON пишет успешный ответ (все текущие ручки возвращают 200). // writeJSON пишет успешный ответ (все текущие ручки возвращают 200).
func writeJSON(w http.ResponseWriter, body any) { func writeJSON(w http.ResponseWriter, body any) {
httpx.WriteJSON(w, http.StatusOK, body) httpx.WriteJSON(w, http.StatusOK, body)
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+147
View File
@@ -1,7 +1,12 @@
package server package server
import ( import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest"
"testing" "testing"
"glchat/internal/permissions" "glchat/internal/permissions"
@@ -371,3 +376,145 @@ func TestTypingAndReadState(t *testing.T) {
t.Fatalf("read states = %+v", states) t.Fatalf("read states = %+v", states)
} }
} }
func TestFileUploadAndAccess(t *testing.T) {
f := newMessagingFixture(t)
httpServer := httptest.NewServer(f.srv.Handler())
t.Cleanup(httpServer.Close)
content := []byte("вложение: проверка загрузки")
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "документ.txt")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write part: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
httpServer.URL+"/api/v1/channels/"+f.openChannel+"/files", bytes.NewReader(body.Bytes()))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(f.memberCookie)
resp, err := httpServer.Client().Do(req)
if err != nil {
t.Fatalf("upload: %v", err)
}
payload, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("upload = %d, body = %s", resp.StatusCode, payload)
}
var decoded struct {
File struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
} `json:"file"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode upload: %v (%s)", err, payload)
}
if decoded.File.FileID == "" || decoded.File.Filename != "документ.txt" || decoded.File.SizeBytes != int64(len(content)) {
t.Fatalf("unexpected upload payload: %s", payload)
}
// Скачивание участником комнаты.
downloadReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
httpServer.URL+"/files/"+decoded.File.FileID, nil)
if err != nil {
t.Fatalf("new download request: %v", err)
}
downloadReq.AddCookie(f.memberCookie)
downloadResp, err := httpServer.Client().Do(downloadReq)
if err != nil {
t.Fatalf("download: %v", err)
}
downloaded, _ := io.ReadAll(downloadResp.Body)
_ = downloadResp.Body.Close()
if downloadResp.StatusCode != http.StatusOK || !bytes.Equal(downloaded, content) {
t.Fatalf("download = %d, body = %q", downloadResp.StatusCode, downloaded)
}
if downloadResp.Header.Get("ETag") == "" {
t.Error("ETag header is missing")
}
// Файл из скрытой комнаты недоступен тому, кто её не видит: загружаем
// тот же контент владельцем в «тайную» и проверяем участника.
secretBody := &bytes.Buffer{}
secretWriter := multipart.NewWriter(secretBody)
secretPart, err := secretWriter.CreateFormFile("file", "секрет.txt")
if err != nil {
t.Fatalf("CreateFormFile secret: %v", err)
}
if _, err := secretPart.Write(content); err != nil {
t.Fatalf("write secret part: %v", err)
}
if err := secretWriter.Close(); err != nil {
t.Fatalf("close secret writer: %v", err)
}
secretReq, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
httpServer.URL+"/api/v1/channels/"+f.secretID+"/files", bytes.NewReader(secretBody.Bytes()))
if err != nil {
t.Fatalf("new secret request: %v", err)
}
secretReq.Header.Set("Content-Type", secretWriter.FormDataContentType())
secretReq.AddCookie(f.ownerCookie)
secretResp, err := httpServer.Client().Do(secretReq)
if err != nil {
t.Fatalf("secret upload: %v", err)
}
secretPayload, _ := io.ReadAll(secretResp.Body)
_ = secretResp.Body.Close()
if secretResp.StatusCode != http.StatusOK {
t.Fatalf("secret upload = %d, body = %s", secretResp.StatusCode, secretPayload)
}
var secretFile struct {
File struct {
FileID string `json:"file_id"`
} `json:"file"`
}
if err := json.Unmarshal(secretPayload, &secretFile); err != nil {
t.Fatalf("decode secret upload: %v", err)
}
hiddenReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
httpServer.URL+"/files/"+secretFile.File.FileID, nil)
if err != nil {
t.Fatalf("new hidden request: %v", err)
}
hiddenReq.AddCookie(f.memberCookie)
hiddenResp, err := httpServer.Client().Do(hiddenReq)
if err != nil {
t.Fatalf("hidden download: %v", err)
}
_ = hiddenResp.Body.Close()
if hiddenResp.StatusCode != http.StatusNotFound {
t.Fatalf("hidden channel file = %d, want 404", hiddenResp.StatusCode)
}
// Вложение прикрепляется к сообщению и попадает в ответ API.
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"","attachment_ids":["`+decoded.File.FileID+`"]}`, f.memberCookie)
if sent.Code != http.StatusOK {
t.Fatalf("message with attachment = %d, body = %s", sent.Code, sent.Body.String())
}
message := decodeResponse[struct {
Message struct {
Attachments []struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
} `json:"attachments"`
} `json:"message"`
}](t, sent)
if len(message.Message.Attachments) != 1 || message.Message.Attachments[0].FileID != decoded.File.FileID {
t.Fatalf("attachments = %+v", message.Message.Attachments)
}
}
+5
View File
@@ -101,6 +101,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerInstanceRoutes(s.api) s.registerInstanceRoutes(s.api)
s.registerMessageRoutes(s.api) s.registerMessageRoutes(s.api)
s.registerInviteRoutes(s.api) s.registerInviteRoutes(s.api)
s.registerFileRoutes(s.api, apiRouter)
} }
apiRouter.Get("/openapi.json", s.handleOpenAPI) apiRouter.Get("/openapi.json", s.handleOpenAPI)
}) })
@@ -153,6 +154,10 @@ func (s *Server) registerRoutes(router chi.Router) {
}) })
} }
if s.store != nil {
s.registerFileDownload(router)
}
router.NotFound(s.handleFallback) router.NotFound(s.handleFallback)
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed") httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
+14 -7
View File
@@ -27,23 +27,30 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
const [mounted, setMounted] = useState(open); const [mounted, setMounted] = useState(open);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const [prevOpen, setPrevOpen] = useState(open);
// Свежий onClose без перезапуска эффекта фокуса при каждом рендере. // Свежий onClose без перезапуска эффекта фокуса при каждом рендере.
const onCloseRef = useRef(onClose); const onCloseRef = useRef(onClose);
useEffect(() => { useEffect(() => {
onCloseRef.current = onClose; onCloseRef.current = onClose;
}, [onClose]); }, [onClose]);
// Монтирование/размонтирование с учётом анимации закрытия. // Подстройка состояния под изменившийся проп во время рендера (документированный
useEffect(() => { // приём React): эффект дал бы лишний кадр без анимации появления.
if (prevOpen !== open) {
setPrevOpen(open);
if (open) { if (open) {
setMounted(true); setMounted(true);
setClosing(false); setClosing(false);
return; } else if (mounted) {
}
if (!mounted) {
return;
}
setClosing(true); setClosing(true);
}
}
// Узел уходит из DOM после обратной анимации; при reduced-motion — сразу.
useEffect(() => {
if (open || !mounted) {
return;
}
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setMounted(false); setMounted(false);
setClosing(false); setClosing(false);
+1 -6
View File
@@ -137,12 +137,7 @@ export function UserPanel() {
</button> </button>
</div> </div>
<Menu <Menu open={menuOpen} label={t('user.menu.label')} onClose={closeMenu} anchorRef={triggerRef}>
open={menuOpen}
label={t('user.menu.label')}
onClose={closeMenu}
anchorRef={triggerRef}
>
<MenuGroupLabel>{displayName}</MenuGroupLabel> <MenuGroupLabel>{displayName}</MenuGroupLabel>
<MenuItem icon="👤" onClick={goTo('/settings/account/profile')}> <MenuItem icon="👤" onClick={goTo('/settings/account/profile')}>
{t('user.menu.account')} {t('user.menu.account')}
@@ -26,8 +26,7 @@ export function GuildGeneralSection({ guild, canEdit }: GuildGeneralSectionProps
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: () => updateGuild(guild.id, { name: name.trim(), description: description.trim() }),
updateGuild(guild.id, { name: name.trim(), description: description.trim() }),
onSuccess: (updated) => { onSuccess: (updated) => {
queryClient.setQueryData(guildQueryKey(guild.id), updated); queryClient.setQueryData(guildQueryKey(guild.id), updated);
// Рейка и сайдбар берут имя из снапшота — обновляем и его. // Рейка и сайдбар берут имя из снапшота — обновляем и его.
@@ -1,4 +1,4 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -29,23 +29,18 @@ export function GuildNicknameSection({
}: GuildNicknameSectionProps) { }: GuildNicknameSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [nickname, setNickname] = useState(currentNickname); // `null` — поле ещё не трогали, поэтому показываем ник из снапшота/участников
const [edited, setEdited] = useState(false); // (он может прийти уже после монтирования секции).
const [typed, setTyped] = useState<string | null>(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const nickname = typed ?? currentNickname;
// Ник может прийти из списка участников уже после монтирования секции:
// подтягиваем его, пока пользователь не начал править поле сам.
useEffect(() => {
if (!edited) {
setNickname(currentNickname);
}
}, [currentNickname, edited]);
const save = useMutation({ const save = useMutation({
mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }), mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }),
onSuccess: (_result, value) => { onSuccess: (_result, value) => {
useSessionStore.getState().setMyNickname(guildId, value); useSessionStore.getState().setMyNickname(guildId, value);
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) }); void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) });
setTyped(null);
setSaved(true); setSaved(true);
}, },
}); });
@@ -81,10 +76,7 @@ export function GuildNicknameSection({
value={nickname} value={nickname}
maxLength={32} maxLength={32}
disabled={userId === undefined} disabled={userId === undefined}
onChange={(event) => { onChange={(event) => setTyped(event.target.value)}
setEdited(true);
setNickname(event.target.value);
}}
/> />
{save.isError ? <ErrorNotice error={save.error} /> : null} {save.isError ? <ErrorNotice error={save.error} /> : null}
@@ -54,7 +54,10 @@ export default function ServersSettingsPage() {
} }
}, [sessionGuilds.length, myGuilds.data, upsertGuild]); }, [sessionGuilds.length, myGuilds.data, upsertGuild]);
const guilds = sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? []); const guilds = useMemo(
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
[sessionGuilds, myGuilds.data],
);
const active = useMemo( const active = useMemo(
() => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)), () => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)),
[guilds, routeGuildId], [guilds, routeGuildId],
+9 -2
View File
@@ -172,6 +172,11 @@ interface SnapshotGuildInput {
id: string; id: string;
name: string; name: string;
is_main?: boolean; is_main?: boolean;
/** Владелец сервера: по умолчанию текущий пользователь (`user-1`). */
owner_id?: string;
icon_file_id?: string;
my_permissions?: string[];
my_nickname?: string;
channels?: { channels?: {
id: string; id: string;
name: string; name: string;
@@ -196,8 +201,10 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
guilds: guilds.map((guild) => ({ guilds: guilds.map((guild) => ({
id: guild.id, id: guild.id,
name: guild.name, name: guild.name,
owner_id: 'user-1', owner_id: guild.owner_id ?? 'user-1',
is_main: guild.is_main ?? false, is_main: guild.is_main ?? false,
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
...(guild.my_nickname === undefined ? {} : { my_nickname: guild.my_nickname }),
channels: (guild.channels ?? []).map((channel, index) => ({ channels: (guild.channels ?? []).map((channel, index) => ({
id: channel.id, id: channel.id,
name: channel.name, name: channel.name,
@@ -210,7 +217,7 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
roles: [], roles: [],
member_ids: ['user-1'], member_ids: ['user-1'],
my_role_ids: [], my_role_ids: [],
my_permissions: [], my_permissions: guild.my_permissions ?? [],
})), })),
dm_channels: [], dm_channels: [],
read_states: [], read_states: [],
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
type FetchRoute,
} from './helpers';
import type { GuildMember, GuildSummary, Role } from '@/api/types';
const user = makeUser();
const alpha: GuildSummary = {
id: 'g-1',
name: 'Альфа',
icon_file_id: 'icon-1',
owner_id: 'user-1',
is_main: false,
member_count: 2,
my_role_ids: [],
my_permissions: ['MANAGE_GUILD'],
};
const beta: GuildSummary = {
id: 'g-2',
name: 'Бета',
owner_id: 'user-9',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: [],
};
const members: GuildMember[] = [
{
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
nickname: 'Аля',
status: 'online',
is_instance_admin: false,
joined_at: '2026-09-01T10:00:00Z',
role_ids: ['r-1'],
},
{
user_id: 'user-2',
username: 'bob',
display_name: 'Bob',
status: 'idle',
is_instance_admin: false,
joined_at: '2026-09-02T10:00:00Z',
role_ids: [],
},
];
const roles: Role[] = [
{
id: 'r-1',
name: 'Администратор',
color: 0xff0000,
position: 5,
permissions: 'ADMINISTRATOR',
is_default: false,
hoist: true,
mentionable: true,
},
{
id: 'r-0',
name: 'Все',
color: 0,
position: 0,
permissions: '0',
is_default: true,
hoist: false,
mentionable: false,
},
];
/** Ответ `GET /guilds/{id}`: сводка + описание и роли. */
function guildDetail(summary: GuildSummary, description: string) {
return { ...summary, description, roles };
}
/** Таблица моков страницы настроек серверов. */
function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
return [
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles }) },
{ match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
{ match: '/api/v1/guilds/g-2/members', response: () => json({ members: [members[1]] }) },
{
match: '/api/v1/guilds/g-1',
response: () => json({ guild: guildDetail(alpha, 'Первый сервер') }),
},
{
match: '/api/v1/guilds/g-2',
response: () => json({ guild: guildDetail(beta, 'Второй сервер') }),
},
...extra,
];
}
/** Первая кнопка «Сохранить» на странице (секция «Общее»). */
function saveButton(index = 0): HTMLElement {
const buttons = screen.getAllByRole('button', { name: 'Сохранить' });
const button = buttons[index];
if (button === undefined) {
throw new Error('кнопка сохранения не найдена');
}
return button;
}
describe('настройки серверов', () => {
it('показывает список серверов с иконкой и именем и выбирает первый', async () => {
installFetch(serversRoutes());
const { router } = renderApp('/settings/servers');
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers/g-1');
});
// После выбора первого сервера страница перерисовывается — берём список заново.
const picker = await screen.findByTestId('guild-picker');
const first = within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' });
expect(first).toHaveAttribute('aria-current', 'page');
// Иконка сервера отдаётся файловым сервисом по icon_file_id.
expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1');
expect(
within(picker).getByRole('link', { name: 'Открыть настройки сервера Бета' }),
).toBeVisible();
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
});
it('переключение сервера меняет отображаемые настройки и права', async () => {
installFetch(serversRoutes());
const { router } = renderApp('/settings/servers/g-1');
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
// Владелец может редактировать.
expect(screen.getByLabelText('Название сервера')).toBeEnabled();
await userEvent.click(
await screen.findByRole('link', { name: 'Открыть настройки сервера Бета' }),
);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers/g-2');
});
await waitFor(() => {
expect(screen.getByLabelText('Название сервера')).toHaveValue('Бета');
});
// Участник без прав видит поля только для чтения и подсказку.
expect(screen.getByLabelText('Название сервера')).toBeDisabled();
expect(screen.getByText(/Редактирование доступно владельцу/)).toBeVisible();
});
it('сохраняет имя и описание сервера через PATCH /guilds/{id}', async () => {
const fetchMock = installFetch(
serversRoutes([
{
match: '/api/v1/guilds/g-1',
method: 'PATCH',
response: () => json({ guild: guildDetail({ ...alpha, name: 'Альфа 2' }, 'Новое') }),
},
]),
);
renderApp('/settings/servers/g-1');
const name = await screen.findByLabelText('Название сервера');
await userEvent.clear(name);
await userEvent.type(name, 'Альфа 2');
const description = screen.getByLabelText('Описание');
await userEvent.clear(description);
await userEvent.type(description, 'Новое');
await userEvent.click(saveButton());
expect(await screen.findByText('Настройки сервера сохранены.')).toBeVisible();
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'PATCH' })?.body).toEqual({
name: 'Альфа 2',
description: 'Новое',
});
});
it('сохраняет свой никнейм через PATCH /guilds/{id}/members/{me}', async () => {
const fetchMock = installFetch(
serversRoutes([
{
match: '/api/v1/guilds/g-1/members/user-1',
method: 'PATCH',
response: () => json({ ok: true }),
},
]),
);
renderApp('/settings/servers/g-1');
const nickname = await screen.findByLabelText('Никнейм');
await waitFor(() => {
expect(nickname).toHaveValue('Аля');
});
await userEvent.clear(nickname);
await userEvent.type(nickname, 'Алиса');
// Вторая кнопка «Сохранить» — секция «Мой профиль на сервере».
await userEvent.click(saveButton(1));
expect(await screen.findByText('Никнейм сохранён.')).toBeVisible();
const patch = recordedRequests(fetchMock).find(
(request) => request.url.includes('/members/user-1') && request.method === 'PATCH',
);
expect(patch?.body).toEqual({ nickname: 'Алиса' });
});
it('показывает участников и роли сервера', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-1');
const list = await screen.findByTestId('guild-members');
expect(within(list).getByText('Аля')).toBeVisible();
expect(within(list).getByText('Bob')).toBeVisible();
expect(within(list).getByText('Ролей: 1')).toBeVisible();
const roleList = await screen.findByTestId('guild-roles');
expect(within(roleList).getByText('Администратор')).toBeVisible();
expect(within(roleList).getByText('Позиция 5')).toBeVisible();
});
it('владелец не может покинуть сервер, но может его удалить', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-1');
expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible();
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
expect(screen.getByRole('button', { name: 'Удалить сервер' })).toBeVisible();
});
it('участник без прав не видит удаление сервера', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-2');
expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible();
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
});
it('покинуть сервер можно после подтверждения в модальном окне', async () => {
const fetchMock = installFetch(
serversRoutes([
{ match: '/api/v1/guilds/g-2/leave', method: 'POST', response: () => json({ ok: true }) },
]),
);
const { router } = renderApp('/settings/servers/g-2');
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
expect(dialog).toHaveTextContent('Вы выйдете из сервера «Бета»');
await userEvent.click(within(dialog).getByRole('button', { name: 'Покинуть' }));
await waitFor(() => {
expect(router.state.location.pathname).toContain('/app');
});
expect(findRequest(fetchMock, { url: '/guilds/g-2/leave', method: 'POST' })).toBeDefined();
});
it('удаление сервера владельцем уходит в DELETE /guilds/{id}', async () => {
const fetchMock = installFetch(
serversRoutes([
{ match: '/api/v1/guilds/g-1', method: 'DELETE', response: () => json({ ok: true }) },
]),
);
const { router } = renderApp('/settings/servers/g-1');
await userEvent.click(await screen.findByRole('button', { name: 'Удалить сервер' }));
const dialog = await screen.findByRole('dialog', { name: 'Удалить сервер?' });
await userEvent.click(within(dialog).getByRole('button', { name: 'Удалить' }));
await waitFor(() => {
expect(router.state.location.pathname).toContain('/app');
});
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'DELETE' })).toBeDefined();
});
it('без серверов показывает понятную заглушку', async () => {
installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
]);
renderApp('/settings/servers');
expect(await screen.findByText('Пока нет ни одного сервера')).toBeVisible();
expect(screen.getByText('У вас пока нет серверов.')).toBeVisible();
});
});
+49 -6
View File
@@ -56,7 +56,7 @@ describe('настройки: безопасность', () => {
}, },
]); ]);
renderApp('/settings/security'); renderApp('/settings/account/security');
const visitor = userEvent.setup(); const visitor = userEvent.setup();
await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' })); await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' }));
@@ -85,7 +85,7 @@ describe('настройки: безопасность', () => {
]); ]);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { router } = renderApp('/settings/security'); const { router } = renderApp('/settings/account/security');
const visitor = userEvent.setup(); const visitor = userEvent.setup();
expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux'); expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux');
@@ -121,7 +121,7 @@ describe('настройки: безопасность', () => {
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) }, { match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
]); ]);
renderApp('/settings/security'); renderApp('/settings/account/security');
const visitor = userEvent.setup(); const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1'); await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1');
@@ -152,7 +152,7 @@ describe('настройки: безопасность', () => {
}, },
]); ]);
renderApp('/settings/profile'); renderApp('/settings/account/profile');
const visitor = userEvent.setup(); const visitor = userEvent.setup();
const displayName = await screen.findByLabelText('Отображаемое имя'); const displayName = await screen.findByLabelText('Отображаемое имя');
@@ -168,12 +168,55 @@ describe('настройки: безопасность', () => {
it('вкладка «Инстанс» скрыта от не-администратора', async () => { it('вкладка «Инстанс» скрыта от не-администратора', async () => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]); installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
renderApp('/settings/profile'); renderApp('/settings/account/profile');
expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible(); expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible();
expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull(); expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull();
}); });
it('вверху настроек — две соседние кнопки-переключателя', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const { router } = renderApp('/settings/account/profile');
const account = await screen.findByRole('link', { name: 'Настройки сервера' });
const servers = screen.getByRole('link', { name: 'Мои серверы' });
expect(account).toHaveAttribute('aria-current', 'page');
expect(servers).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-0');
await userEvent.click(servers);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers');
});
expect(screen.getByRole('link', { name: 'Мои серверы' })).toHaveAttribute(
'aria-current',
'page',
);
// Подложка переключателя уезжает ко второй кнопке без перезагрузки.
expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-full');
});
it.each([
['/settings', '/settings/account/profile'],
['/settings/profile', '/settings/account/profile'],
['/settings/security', '/settings/account/security'],
['/settings/appearance', '/settings/account/appearance'],
['/settings/instance', '/settings/account/instance'],
])('старый путь %s редиректит на %s', async (from, to) => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
const { router } = renderApp(from);
await waitFor(() => {
expect(router.state.location.pathname).toBe(to);
});
});
it('администратор видит данные инстанса', async () => { it('администратор видит данные инстанса', async () => {
const admin = makeUser({ is_instance_admin: true }); const admin = makeUser({ is_instance_admin: true });
installFetch([ installFetch([
@@ -220,7 +263,7 @@ describe('настройки: безопасность', () => {
}, },
]); ]);
renderApp('/settings/instance'); renderApp('/settings/account/instance');
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main'); expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice'); expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');
+90 -1
View File
@@ -115,13 +115,102 @@ describe('основной экран', () => {
const panel = screen.getByLabelText('Панель пользователя'); const panel = screen.getByLabelText('Панель пользователя');
expect(within(panel).getByText('Alice')).toBeVisible(); expect(within(panel).getByText('Alice')).toBeVisible();
expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible(); expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible();
expect(within(panel).getByLabelText('Выйти')).toBeVisible(); // Выход переехал во всплывающее меню (проверяется в tests/userMenu.test.tsx).
expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible();
// Заглушка комнаты вместо чата Фазы 1: шапка комнаты + подсказка. // Заглушка комнаты вместо чата Фазы 1: шапка комнаты + подсказка.
expect(await screen.findByText('Чат появится в Фазе 2')).toBeVisible(); expect(await screen.findByText('Чат появится в Фазе 2')).toBeVisible();
expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible(); expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible();
}); });
it('анимационные классы не мешают рендеру оболочки', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-general');
await screen.findByTestId('guild-rail');
await gateway.greet();
// Каскад списков и анимация появления страницы — обычные CSS-классы.
expect(screen.getByTestId('guild-rail')).toHaveClass('gl-stagger');
expect(document.querySelector('.gl-page')).not.toBeNull();
await waitFor(() => {
expect(screen.getByLabelText('Открыть комнату общий')).toHaveClass('gl-indicator');
});
});
it('шестерёнка настроек сервера видна владельцу и ведёт в настройки', async () => {
const { default: userEvent } = await import('@testing-library/user-event');
const visitor = userEvent.setup();
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{
match: '/api/v1/guilds/g-1',
response: () =>
json({
guild: {
id: 'g-1',
name: 'Основной сервер',
description: 'главный',
owner_id: 'user-1',
is_main: true,
member_count: 1,
roles: [],
my_role_ids: [],
my_permissions: [],
},
}),
},
]);
const gateway = installGatewaySocket(snapshot);
const { router } = renderApp('/app/g-1/c-general');
await screen.findByTestId('guild-rail');
await gateway.greet();
const gear = await screen.findByLabelText('Настройки сервера');
await visitor.click(gear);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers/g-1');
});
});
it('обычный участник не видит шестерёнку настроек сервера', async () => {
const memberSnapshot = makeReadySnapshot([
{
id: 'g-9',
name: 'Чужой сервер',
owner_id: 'user-2',
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', position: 0 }],
},
]);
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds/g-9/channels', response: () => json({ channels: [] }) },
]);
const gateway = installGatewaySocket(memberSnapshot);
renderApp('/app/g-9/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
await screen.findByLabelText('Открыть комнату общий');
expect(screen.queryByLabelText('Настройки сервера')).toBeNull();
expect(screen.queryByLabelText('Создать комнату')).toBeNull();
});
it('пустой список серверов предлагает главный сервер и создание своего', async () => { it('пустой список серверов предлагает главный сервер и создание своего', async () => {
installFetch([ installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/users/@me', response: () => json({ user }) },
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
import type { FetchRoute } from './helpers';
const user = makeUser();
/** Базовые ответы для оболочки приложения без серверов. */
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
return [
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
...extra,
];
}
/** Открывает нижне-левое меню пользователя кликом по аватару/имени. */
async function openMenu() {
const trigger = await screen.findByLabelText('Открыть меню пользователя');
await userEvent.click(trigger);
const menu = await screen.findByRole('menu', { name: 'Меню пользователя' });
return { trigger, menu };
}
describe('меню пользователя', () => {
it('открывается по клику на аватар и содержит все пункты', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const { menu } = await openMenu();
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
expect(
within(menu).getByRole('menuitemradio', { name: 'В сети', checked: true }),
).toBeVisible();
expect(within(menu).getByRole('menuitemradio', { name: 'Не беспокоить' })).toBeVisible();
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
});
it('шестерёнка открывает то же меню', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
expect(await screen.findByRole('menu', { name: 'Меню пользователя' })).toBeVisible();
});
it('пункт «Настройки аккаунта» ведёт в /settings/account/profile', async () => {
installFetch(baseRoutes());
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/account/profile');
});
expect(await screen.findByRole('heading', { name: 'Профиль' })).toBeVisible();
});
it('пункт «Настройки серверов» ведёт в /settings/servers', async () => {
installFetch(baseRoutes());
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers');
});
expect(await screen.findByText('У вас пока нет серверов.')).toBeVisible();
});
it('смена статуса уходит в PATCH /users/@me и отмечает текущий статус', async () => {
const fetchMock = installFetch(
baseRoutes([
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, status: 'invisible' } }),
},
]),
);
renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitemradio', { name: 'Невидимый' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
status: 'invisible',
});
});
// Меню закрывается, а статус в панели обновляется из ответа сервера.
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
expect(screen.getByLabelText('Панель пользователя')).toHaveTextContent('Невидимый');
});
it('«Выйти» вызывает POST /auth/logout и уводит на /login', async () => {
const fetchMock = installFetch(
baseRoutes([
{ match: '/api/v1/auth/logout', method: 'POST', response: () => json({ ok: true }) },
]),
);
const { router } = renderApp('/app/empty');
const { menu } = await openMenu();
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Выйти' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
expect(
recordedRequests(fetchMock).some(
(request) => request.url.includes('/auth/logout') && request.method === 'POST',
),
).toBe(true);
});
it('закрывается по Escape и возвращает фокус на кнопку', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const { trigger } = await openMenu();
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
expect(trigger).toHaveFocus();
});
it('закрывается по клику вне меню', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
await openMenu();
await userEvent.click(document.body);
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull();
});
});
it('доступно с клавиатуры: фокус на первом пункте и стрелки', async () => {
installFetch(baseRoutes());
renderApp('/app/empty');
const trigger = await screen.findByLabelText('Открыть меню пользователя');
trigger.focus();
await userEvent.keyboard('{Enter}');
const first = await screen.findByRole('menuitem', { name: 'Настройки аккаунта' });
await waitFor(() => {
expect(first).toHaveFocus();
});
await userEvent.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: 'Настройки серверов' })).toHaveFocus();
await userEvent.keyboard('{ArrowUp}');
expect(screen.getByRole('menuitem', { name: 'Настройки аккаунта' })).toHaveFocus();
});
});