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:
@@ -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)
|
||||
|
||||
@@ -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: ¤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}})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -0,0 +1 @@
|
||||
вложение: проверка загрузки
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -27,23 +27,30 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(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);
|
||||
|
||||
@@ -137,12 +137,7 @@ export function UserPanel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
label={t('user.menu.label')}
|
||||
onClose={closeMenu}
|
||||
anchorRef={triggerRef}
|
||||
>
|
||||
<Menu open={menuOpen} label={t('user.menu.label')} onClose={closeMenu} anchorRef={triggerRef}>
|
||||
<MenuGroupLabel>{displayName}</MenuGroupLabel>
|
||||
<MenuItem icon="👤" onClick={goTo('/settings/account/profile')}>
|
||||
{t('user.menu.account')}
|
||||
|
||||
@@ -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);
|
||||
// Рейка и сайдбар берут имя из снапшота — обновляем и его.
|
||||
|
||||
@@ -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<string | null>(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 ? <ErrorNotice error={save.error} /> : null}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user