feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс

По запросу пользователя (вне очереди AGENT.md §13):

- главного сервера как точки входа больше нет: новичок начинает с пустым
  списком серверов, вход — только по приглашению или созданием своего;
- друзья: поиск по логину с экранированием LIKE, заявки (POST
  /users/@me/relationships), принятие, удаление/отклонение, списки friends/
  incoming/outgoing/blocked;
- личные беседы: POST /users/@me/channels (идемпотентно), GET
  /users/@me/channels со собеседником, статусом и последним сообщением;
  сообщения в DM работают через общие ручки комнат, доступ — только участникам
  (посторонний получает 404, события в Gateway тоже фильтруются);
- присутствие: last_seen_at обновляется при активности, «невидимка» и простой
  дольше двух минут выглядят как офлайн, смена статуса рассылает
  PRESENCE_UPDATE друзьям;
- READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение);
- профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me,
  загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том
  числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл
  удаляется с диска;
- тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой
  пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
This commit is contained in:
2026-09-20 01:11:04 +03:00
parent b01eb1f34c
commit 33dc1fec96
19 changed files with 1738 additions and 82 deletions
+29 -4
View File
@@ -117,6 +117,31 @@ func send(t *testing.T, conn *websocket.Conn, op int, payload any) {
}
}
// joinMainGuildForTest добавляет пользователя в главный сервер: в продакшене
// это делает принятие приглашения.
func joinMainGuildForTest(t *testing.T, f *fixture, token string) {
t.Helper()
ctx := context.Background()
user, _, err := f.auth.ResolveSession(ctx, token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
guild, err := f.store.GetMainGuild(ctx)
if err != nil {
t.Fatalf("GetMainGuild: %v", err)
}
defaultRole, err := f.store.DefaultRole(ctx, guild.ID)
if err != nil {
t.Fatalf("DefaultRole: %v", err)
}
if _, err := f.store.AddGuildMember(ctx, guild.ID, user.ID, ""); err != nil {
t.Fatalf("AddGuildMember: %v", err)
}
if err := f.store.AssignRole(ctx, guild.ID, user.ID, defaultRole.ID); err != nil {
t.Fatalf("AssignRole: %v", err)
}
}
func registerUser(t *testing.T, f *fixture, username, email string) string {
t.Helper()
_, token, _, err := f.auth.Register(context.Background(), auth.RegisterInput{
@@ -131,6 +156,8 @@ func registerUser(t *testing.T, f *fixture, username, email string) string {
func TestHelloAndReady(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "gateway_user", "gateway@example.com")
// Автовступления больше нет: в сервер вступаем явно, как по приглашению.
joinMainGuildForTest(t, f, token)
conn, hello := f.dial(t)
var helloPayload struct {
@@ -164,12 +191,9 @@ func TestHelloAndReady(t *testing.T) {
t.Fatalf("READY user = %q", snapshot.User.Username)
}
if len(snapshot.Guilds) != 1 {
t.Fatalf("new user must see exactly the main guild, got %d", len(snapshot.Guilds))
t.Fatalf("user must see the guild they joined, got %d", len(snapshot.Guilds))
}
guild := snapshot.Guilds[0]
if !guild.IsMain {
t.Fatal("the main guild must be marked is_main")
}
if len(guild.Roles) != 2 {
t.Fatalf("main guild roles = %d, want 2", len(guild.Roles))
}
@@ -216,6 +240,7 @@ func TestReadyHidesPrivateChannels(t *testing.T) {
}
token := registerUser(t, f, "private_user", "private@example.com")
joinMainGuildForTest(t, f, token)
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
ready := readEnvelope(t, conn)