diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index ab9e3e6..addef14 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -62,6 +62,20 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) 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) if err != nil { return nil, fmt.Errorf("list user guilds: %w", err) diff --git a/internal/server/api_files.go b/internal/server/api_files.go new file mode 100644 index 0000000..1c58cea --- /dev/null +++ b/internal/server/api_files.go @@ -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./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: ¤tUser.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}}) +} diff --git a/internal/server/errors.go b/internal/server/errors.go index b50d119..e600c22 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -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). func writeJSON(w http.ResponseWriter, body any) { httpx.WriteJSON(w, http.StatusOK, body) diff --git a/internal/server/files/94894031435202560 b/internal/server/files/94894031435202560 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894031435202560 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894082492465152 b/internal/server/files/94894082492465152 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894082492465152 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894120186675200 b/internal/server/files/94894120186675200 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894120186675200 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894120199258112 b/internal/server/files/94894120199258112 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894120199258112 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894158157709312 b/internal/server/files/94894158157709312 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894158157709312 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894158166097920 b/internal/server/files/94894158166097920 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894158166097920 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894189732429824 b/internal/server/files/94894189732429824 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894189732429824 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894189753401344 b/internal/server/files/94894189753401344 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894189753401344 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894341939527680 b/internal/server/files/94894341939527680 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894341939527680 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/files/94894341952110592 b/internal/server/files/94894341952110592 new file mode 100644 index 0000000..c717552 --- /dev/null +++ b/internal/server/files/94894341952110592 @@ -0,0 +1 @@ +вложение: проверка загрузки \ No newline at end of file diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index 600ea81..ed67587 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -1,7 +1,12 @@ package server import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" "net/http" + "net/http/httptest" "testing" "glchat/internal/permissions" @@ -371,3 +376,145 @@ func TestTypingAndReadState(t *testing.T) { 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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 78ba75a..7ce9416 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -101,6 +101,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerInstanceRoutes(s.api) s.registerMessageRoutes(s.api) s.registerInviteRoutes(s.api) + s.registerFileRoutes(s.api, apiRouter) } 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.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed") diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx index 6a4aaf2..a4adcf0 100644 --- a/web/src/components/ui/Modal.tsx +++ b/web/src/components/ui/Modal.tsx @@ -27,23 +27,30 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) { const panelRef = useRef(null); const [mounted, setMounted] = useState(open); const [closing, setClosing] = useState(false); + const [prevOpen, setPrevOpen] = useState(open); // Свежий onClose без перезапуска эффекта фокуса при каждом рендере. const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; }, [onClose]); - // Монтирование/размонтирование с учётом анимации закрытия. - useEffect(() => { + // Подстройка состояния под изменившийся проп во время рендера (документированный + // приём React): эффект дал бы лишний кадр без анимации появления. + if (prevOpen !== open) { + setPrevOpen(open); if (open) { setMounted(true); setClosing(false); + } else if (mounted) { + setClosing(true); + } + } + + // Узел уходит из DOM после обратной анимации; при reduced-motion — сразу. + useEffect(() => { + if (open || !mounted) { return; } - if (!mounted) { - return; - } - setClosing(true); const timer = window.setTimeout(() => { setMounted(false); setClosing(false); diff --git a/web/src/pages/app/UserPanel.tsx b/web/src/pages/app/UserPanel.tsx index a869c93..8eb81d6 100644 --- a/web/src/pages/app/UserPanel.tsx +++ b/web/src/pages/app/UserPanel.tsx @@ -137,12 +137,7 @@ export function UserPanel() { - + {displayName} {t('user.menu.account')} diff --git a/web/src/pages/settings/GuildGeneralSection.tsx b/web/src/pages/settings/GuildGeneralSection.tsx index 162eff9..e57c0b1 100644 --- a/web/src/pages/settings/GuildGeneralSection.tsx +++ b/web/src/pages/settings/GuildGeneralSection.tsx @@ -26,8 +26,7 @@ export function GuildGeneralSection({ guild, canEdit }: GuildGeneralSectionProps const [saved, setSaved] = useState(false); const save = useMutation({ - mutationFn: () => - updateGuild(guild.id, { name: name.trim(), description: description.trim() }), + mutationFn: () => updateGuild(guild.id, { name: name.trim(), description: description.trim() }), onSuccess: (updated) => { queryClient.setQueryData(guildQueryKey(guild.id), updated); // Рейка и сайдбар берут имя из снапшота — обновляем и его. diff --git a/web/src/pages/settings/GuildNicknameSection.tsx b/web/src/pages/settings/GuildNicknameSection.tsx index 11970ba..c731399 100644 --- a/web/src/pages/settings/GuildNicknameSection.tsx +++ b/web/src/pages/settings/GuildNicknameSection.tsx @@ -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 { useTranslation } from 'react-i18next'; @@ -29,23 +29,18 @@ export function GuildNicknameSection({ }: GuildNicknameSectionProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); - const [nickname, setNickname] = useState(currentNickname); - const [edited, setEdited] = useState(false); + // `null` — поле ещё не трогали, поэтому показываем ник из снапшота/участников + // (он может прийти уже после монтирования секции). + const [typed, setTyped] = useState(null); const [saved, setSaved] = useState(false); - - // Ник может прийти из списка участников уже после монтирования секции: - // подтягиваем его, пока пользователь не начал править поле сам. - useEffect(() => { - if (!edited) { - setNickname(currentNickname); - } - }, [currentNickname, edited]); + const nickname = typed ?? currentNickname; const save = useMutation({ mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }), onSuccess: (_result, value) => { useSessionStore.getState().setMyNickname(guildId, value); void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) }); + setTyped(null); setSaved(true); }, }); @@ -81,10 +76,7 @@ export function GuildNicknameSection({ value={nickname} maxLength={32} disabled={userId === undefined} - onChange={(event) => { - setEdited(true); - setNickname(event.target.value); - }} + onChange={(event) => setTyped(event.target.value)} /> {save.isError ? : null} diff --git a/web/src/pages/settings/ServersSettingsPage.tsx b/web/src/pages/settings/ServersSettingsPage.tsx index aca575c..f021814 100644 --- a/web/src/pages/settings/ServersSettingsPage.tsx +++ b/web/src/pages/settings/ServersSettingsPage.tsx @@ -54,7 +54,10 @@ export default function ServersSettingsPage() { } }, [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( () => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)), [guilds, routeGuildId], diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx index 776fbee..9a79ba2 100644 --- a/web/tests/helpers.tsx +++ b/web/tests/helpers.tsx @@ -172,6 +172,11 @@ interface SnapshotGuildInput { id: string; name: string; is_main?: boolean; + /** Владелец сервера: по умолчанию текущий пользователь (`user-1`). */ + owner_id?: string; + icon_file_id?: string; + my_permissions?: string[]; + my_nickname?: string; channels?: { id: string; name: string; @@ -196,8 +201,10 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) { guilds: guilds.map((guild) => ({ id: guild.id, name: guild.name, - owner_id: 'user-1', + owner_id: guild.owner_id ?? 'user-1', 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) => ({ id: channel.id, name: channel.name, @@ -210,7 +217,7 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) { roles: [], member_ids: ['user-1'], my_role_ids: [], - my_permissions: [], + my_permissions: guild.my_permissions ?? [], })), dm_channels: [], read_states: [], diff --git a/web/tests/servers.test.tsx b/web/tests/servers.test.tsx new file mode 100644 index 0000000..eb729ad --- /dev/null +++ b/web/tests/servers.test.tsx @@ -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(); + }); +}); diff --git a/web/tests/settings.test.tsx b/web/tests/settings.test.tsx index 82ef3bb..b03e82f 100644 --- a/web/tests/settings.test.tsx +++ b/web/tests/settings.test.tsx @@ -56,7 +56,7 @@ describe('настройки: безопасность', () => { }, ]); - renderApp('/settings/security'); + renderApp('/settings/account/security'); const visitor = userEvent.setup(); await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' })); @@ -85,7 +85,7 @@ describe('настройки: безопасность', () => { ]); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); - const { router } = renderApp('/settings/security'); + const { router } = renderApp('/settings/account/security'); const visitor = userEvent.setup(); 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 }) }, ]); - renderApp('/settings/security'); + renderApp('/settings/account/security'); const visitor = userEvent.setup(); 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 displayName = await screen.findByLabelText('Отображаемое имя'); @@ -168,12 +168,55 @@ describe('настройки: безопасность', () => { it('вкладка «Инстанс» скрыта от не-администратора', async () => { installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]); - renderApp('/settings/profile'); + renderApp('/settings/account/profile'); expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible(); 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 () => { const admin = makeUser({ is_instance_admin: true }); installFetch([ @@ -220,7 +263,7 @@ describe('настройки: безопасность', () => { }, ]); - renderApp('/settings/instance'); + renderApp('/settings/account/instance'); expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main'); expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice'); diff --git a/web/tests/shell.test.tsx b/web/tests/shell.test.tsx index 4fa7ead..6f78fd6 100644 --- a/web/tests/shell.test.tsx +++ b/web/tests/shell.test.tsx @@ -115,13 +115,102 @@ describe('основной экран', () => { const panel = screen.getByLabelText('Панель пользователя'); expect(within(panel).getByText('Alice')).toBeVisible(); expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible(); - expect(within(panel).getByLabelText('Выйти')).toBeVisible(); + // Выход переехал во всплывающее меню (проверяется в tests/userMenu.test.tsx). + expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible(); // Заглушка комнаты вместо чата Фазы 1: шапка комнаты + подсказка. expect(await screen.findByText('Чат появится в Фазе 2')).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 () => { installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, diff --git a/web/tests/userMenu.test.tsx b/web/tests/userMenu.test.tsx new file mode 100644 index 0000000..72909a7 --- /dev/null +++ b/web/tests/userMenu.test.tsx @@ -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(); + }); +});