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
+12 -10
View File
@@ -156,7 +156,10 @@ func TestBootstrapIsIdempotent(t *testing.T) {
}
}
func TestNewUserJoinsMainGuild(t *testing.T) {
// TestNewcomerStartsWithoutGuilds фиксирует решение пользователя: главного
// сервера как точки автовхода нет — новичок начинает с пустого списка, а вход
// в сервер возможен только по приглашению или созданием своего.
func TestNewcomerStartsWithoutGuilds(t *testing.T) {
ctx := context.Background()
runner, st, authService := newRunner(t)
result, err := runner.Run(ctx, bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"})
@@ -174,17 +177,16 @@ func TestNewUserJoinsMainGuild(t *testing.T) {
if err != nil {
t.Fatalf("ListGuildsForUser: %v", err)
}
if len(guilds) != 1 || guilds[0].ID != result.GuildID {
t.Fatalf("newcomer must join the main guild: %+v", guilds)
if len(guilds) != 0 {
t.Fatalf("newcomer must start without guilds: %+v", guilds)
}
roles, err := st.MemberRoles(ctx, result.GuildID, user.ID)
// Сервер установщика остаётся рабочим: владелец в нём с ролью администратора.
ownerRoles, err := st.MemberRoles(ctx, result.GuildID, result.UserID)
if err != nil {
t.Fatalf("MemberRoles: %v", err)
t.Fatalf("MemberRoles(owner): %v", err)
}
if len(roles) != 1 || !roles[0].IsDefault {
t.Fatalf("newcomer must get the default role: %+v", roles)
}
if !permissions.Permission(roles[0].Permissions).Has(permissions.ViewGuild) {
t.Fatal("default role must allow viewing the guild")
if len(ownerRoles) != 1 || !permissions.Permission(ownerRoles[0].Permissions).Has(permissions.Administrator) {
t.Fatalf("owner must keep the administrator role: %+v", ownerRoles)
}
}