Files
grendervill 33dc1fec96 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 у постороннего.
2026-09-20 01:11:04 +03:00

193 lines
5.8 KiB
Go

package bootstrap_test
import (
"context"
"log/slog"
"path/filepath"
"testing"
"glchat/internal/auth"
"glchat/internal/bootstrap"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/permissions"
"glchat/internal/store"
)
func newRunner(t *testing.T) (*bootstrap.Runner, *store.Store, *auth.Service) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true,
})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
st := store.New(db)
logger := slog.New(slog.DiscardHandler)
cfg := config.Config{
SessionPepper: "bootstrap-pepper",
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
Argon2MemoryKiB: 1024,
Argon2Iterations: 1,
Argon2Parallelism: 1,
}
authService, err := auth.New(ctx, cfg, st, logger)
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return bootstrap.New(st, authService, logger), st, authService
}
func TestBootstrapCreatesAdminAndMainGuild(t *testing.T) {
ctx := context.Background()
runner, st, _ := newRunner(t)
result, err := runner.Run(ctx, bootstrap.Options{
Email: "owner@example.com",
Password: "correct-horse-battery",
MainGuild: "Главный сервер",
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !result.CreatedUser || !result.CreatedGuild {
t.Fatalf("unexpected result: %+v", result)
}
admin, err := st.GetUser(ctx, result.UserID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if !admin.IsInstanceAdmin {
t.Fatal("bootstrap admin must have the instance admin flag")
}
if len(admin.Badges) == 0 || admin.Badges[0] != "instance_admin" {
t.Fatalf("admin badge is missing: %+v", admin.Badges)
}
settings, err := st.InstanceSettings(ctx)
if err != nil {
t.Fatalf("InstanceSettings: %v", err)
}
if settings.MainGuildID != result.GuildID {
t.Fatalf("main guild id = %d, want %d", settings.MainGuildID, result.GuildID)
}
roles, err := st.ListGuildRoles(ctx, result.GuildID)
if err != nil {
t.Fatalf("ListGuildRoles: %v", err)
}
if len(roles) != 2 {
t.Fatalf("main guild must have 2 default roles, got %d", len(roles))
}
var defaultRole *store.Role
for i := range roles {
if roles[i].IsDefault {
defaultRole = &roles[i]
}
}
if defaultRole == nil {
t.Fatal("default role is missing")
}
if permissions.Permission(defaultRole.Permissions) != permissions.DefaultUserPermissions {
t.Fatalf("default role permissions = %s", permissions.Permission(defaultRole.Permissions))
}
// Владелец — участник с ролью администратора.
memberRoles, err := st.MemberRoles(ctx, result.GuildID, result.UserID)
if err != nil {
t.Fatalf("MemberRoles: %v", err)
}
found := false
for _, role := range memberRoles {
if role.ID == result.AdminRoleID {
found = true
}
}
if !found {
t.Fatal("owner must receive the administrator role")
}
// Общая текстовая комната создана.
channels, err := st.ListGuildChannels(ctx, result.GuildID)
if err != nil {
t.Fatalf("ListGuildChannels: %v", err)
}
if len(channels) != 1 || channels[0].Name != "общий" {
t.Fatalf("unexpected channels: %+v", channels)
}
}
func TestBootstrapIsIdempotent(t *testing.T) {
ctx := context.Background()
runner, st, authService := newRunner(t)
options := bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"}
first, err := runner.Run(ctx, options)
if err != nil {
t.Fatalf("first Run: %v", err)
}
second, err := runner.Run(ctx, options)
if err != nil {
t.Fatalf("second Run: %v", err)
}
if second.GuildID != first.GuildID {
t.Fatalf("second run created another guild: %d != %d", second.GuildID, first.GuildID)
}
if second.CreatedUser || second.CreatedGuild {
t.Fatalf("second run must not create anything: %+v", second)
}
guilds, err := st.ListAllGuilds(ctx)
if err != nil {
t.Fatalf("ListAllGuilds: %v", err)
}
if len(guilds) != 1 {
t.Fatalf("guild count = %d, want 1", len(guilds))
}
// Новый пользователь может войти с заданным паролем.
if _, _, _, err := authService.Login(ctx, auth.LoginInput{
Email: "owner@example.com", Password: "correct-horse-battery",
}); err == nil {
t.Fatal("instance admin without 2FA must not be able to log in")
}
}
// 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"})
if err != nil {
t.Fatalf("Run: %v", err)
}
user, _, _, err := authService.Register(ctx, auth.RegisterInput{
Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("Register: %v", err)
}
guilds, err := st.ListGuildsForUser(ctx, user.ID)
if err != nil {
t.Fatalf("ListGuildsForUser: %v", err)
}
if len(guilds) != 0 {
t.Fatalf("newcomer must start without guilds: %+v", guilds)
}
// Сервер установщика остаётся рабочим: владелец в нём с ролью администратора.
ownerRoles, err := st.MemberRoles(ctx, result.GuildID, result.UserID)
if err != nil {
t.Fatalf("MemberRoles(owner): %v", err)
}
if len(ownerRoles) != 1 || !permissions.Permission(ownerRoles[0].Permissions).Has(permissions.Administrator) {
t.Fatalf("owner must keep the administrator role: %+v", ownerRoles)
}
}