package store import ( "context" "errors" "path/filepath" "testing" "time" "glchat/internal/database" ) // newInstanceStore открывает временную БД с миграциями: расширенная // админ-панель (AGENT.md 7.18) читает пользователей, серверы, сообщения и // аудит, поэтому нужен настоящий набор таблиц. func newInstanceStore(t *testing.T) *Store { t.Helper() db, err := database.Open(context.Background(), database.Options{ Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true, }) if err != nil { t.Fatalf("открыть тестовую БД: %v", err) } t.Cleanup(func() { if err := db.Close(); err != nil { t.Errorf("закрыть тестовую БД: %v", err) } }) return New(db) } func createInstanceUser(t *testing.T, st *Store, username string) *User { t.Helper() user, err := st.CreateUser(context.Background(), CreateUserParams{ Username: username, EmailEnc: "enc-" + username, EmailIndex: "idx-" + username, PasswordHash: "hash", }) if err != nil { t.Fatalf("создать пользователя %s: %v", username, err) } return user } // TestInstanceStatsAggregates проверяет сводку панели: пользователи, сообщения, // файлы, серверы и топы (AGENT.md 7.18). func TestInstanceStatsAggregates(t *testing.T) { ctx := context.Background() st := newInstanceStore(t) owner := createInstanceUser(t, st, "stats_owner") member := createInstanceUser(t, st, "stats_member") if err := st.BanInstanceUser(ctx, member.ID, owner.ID, "спам"); err != nil { t.Fatalf("забанить пользователя: %v", err) } if err := st.SetInstanceAdmin(ctx, member.ID, true); err != nil { t.Fatalf("выдать админа: %v", err) } guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Статистика", OwnerID: owner.ID}) if err != nil { t.Fatalf("создать сервер: %v", err) } for _, userID := range []uint64{owner.ID, member.ID} { if _, err := st.AddGuildMember(ctx, guild.ID, userID, ""); err != nil { t.Fatalf("добавить участника: %v", err) } } channel, err := st.CreateChannel(ctx, CreateChannelParams{ GuildID: &guild.ID, Type: ChannelText, Name: "общий", }) if err != nil { t.Fatalf("создать комнату: %v", err) } for i := 0; i < 3; i++ { if _, err := st.CreateMessage(ctx, CreateMessageParams{ ChannelID: channel.ID, AuthorID: owner.ID, Content: "привет", }); err != nil { t.Fatalf("создать сообщение: %v", err) } } // Вложение лежит в files и считается размером хранилища. if _, err := st.CreateFile(ctx, CreateFileParams{ UploaderID: owner.ID, Filename: "a.png", ContentType: "image/png", SizeBytes: 2048, StoragePath: "files/a", SHA256: "sha-stats", Purpose: "attachment", }); err != nil { t.Fatalf("создать файл: %v", err) } if _, err := st.CreateInvite(ctx, CreateInviteParams{ Code: "stats-invite", GuildID: guild.ID, CreatorID: owner.ID, MaxAgeSec: 3600, }); err != nil { t.Fatalf("создать инвайт: %v", err) } // Отозванный инвайт действующим не считается. if _, err := st.CreateInvite(ctx, CreateInviteParams{ Code: "stats-revoked", GuildID: guild.ID, CreatorID: owner.ID, }); err != nil { t.Fatalf("создать инвайт: %v", err) } if err := st.DeleteInvite(ctx, "stats-revoked"); err != nil { t.Fatalf("отозвать инвайт: %v", err) } stats, err := st.InstanceStats(ctx) if err != nil { t.Fatalf("InstanceStats: %v", err) } if stats.Users.Total < 2 || stats.Users.New7Days < 2 || stats.Users.New30Days < 2 { t.Fatalf("пользователи в сводке: %+v", stats.Users) } if stats.Users.Admins != 1 { t.Fatalf("администраторов = %d, want 1", stats.Users.Admins) } if stats.Bans != 1 || stats.Users.Banned != 1 { t.Fatalf("банов = %d/%d, want 1", stats.Bans, stats.Users.Banned) } if len(stats.Users.Daily) == 0 { t.Fatal("рост пользователей по дням пуст") } if stats.Messages.Total != 3 || stats.Messages.Today != 3 || stats.Messages.Month != 3 { t.Fatalf("сообщения в сводке: %+v", stats.Messages) } if len(stats.Messages.Daily) == 0 { t.Fatal("рост сообщений по дням пуст") } if stats.Files.Count != 1 || stats.Files.Bytes != 2048 { t.Fatalf("файлы в сводке: %+v", stats.Files) } if stats.Guilds != 1 { t.Fatalf("серверов = %d, want 1", stats.Guilds) } if stats.Invites != 1 { t.Fatalf("действующих инвайтов = %d, want 1", stats.Invites) } if len(stats.TopGuilds) != 1 || stats.TopGuilds[0].Members != 2 { t.Fatalf("топ по участникам: %+v", stats.TopGuilds) } if len(stats.BusiestGuilds) != 1 || stats.BusiestGuilds[0].Messages != 3 { t.Fatalf("топ по сообщениям: %+v", stats.BusiestGuilds) } } // TestInstanceAuditFilters: журнал инстанса фильтруется по действию, актору, // цели, серверу и периоду, а выдача листается (AGENT.md 7.10, 7.18). func TestInstanceAuditFilters(t *testing.T) { ctx := context.Background() st := newInstanceStore(t) admin := createInstanceUser(t, st, "audit_admin") target := createInstanceUser(t, st, "audit_target") if err := st.SetInstanceAdmin(ctx, admin.ID, true); err != nil { t.Fatalf("выдать админа: %v", err) } record := func(action, targetType string, targetID *uint64, reason string) { t.Helper() if err := st.RecordAudit(ctx, AuditParams{ ActorID: &admin.ID, ActorInstanceAdmin: true, Action: action, TargetType: targetType, TargetID: targetID, Reason: reason, }); err != nil { t.Fatalf("записать аудит %s: %v", action, err) } } record("instance.user_ban", "user", &target.ID, "спам") record("instance.user_unban", "user", &target.ID, "") record("instance.settings_update", "instance", nil, "") // Запись обычного пользователя внутри сервера в журнал инстанса не // попадает, а запись без сервера (например, ключ доступа) — попадает: // так устроена область журнала (AGENT.md 7.18). someone := createInstanceUser(t, st, "audit_someone") guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Аудит", OwnerID: someone.ID}) if err != nil { t.Fatalf("создать сервер: %v", err) } if err := st.RecordAudit(ctx, AuditParams{ ActorID: &someone.ID, GuildID: &guild.ID, Action: "channel.create", TargetType: "channel", }); err != nil { t.Fatalf("записать аудит сервера: %v", err) } if err := st.RecordAudit(ctx, AuditParams{ ActorID: &someone.ID, Action: "user.passkey_add", TargetType: "user", TargetID: &someone.ID, }); err != nil { t.Fatalf("записать аудит без сервера: %v", err) } all, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{}) if err != nil { t.Fatalf("журнал целиком: %v", err) } if total != 4 || len(all) != 4 { t.Fatalf("журнал = %d строк (total %d), want 4", len(all), total) } byAction, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Action: "instance.user_ban"}) if err != nil || total != 1 || len(byAction) != 1 { t.Fatalf("фильтр по действию: %d строк (total %d), err = %v", len(byAction), total, err) } byTarget, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{TargetID: target.ID}) if err != nil || total != 2 { t.Fatalf("фильтр по цели: %d (total %d), err = %v", len(byTarget), total, err) } byActor, actorTotal, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{ActorID: someone.ID}) if err != nil || actorTotal != 1 || len(byActor) != 1 { t.Fatalf("записи без сервера по актору: %d (total %d), err = %v", len(byActor), actorTotal, err) } _, guildTotal, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{GuildID: guild.ID}) if err != nil || guildTotal != 0 { t.Fatalf("аудит сервера не должен попадать в журнал инстанса: total %d, err = %v", guildTotal, err) } // Период: сегодняшние записи видны, «завтра» — нет. tomorrow := time.Now().UTC().AddDate(0, 0, 1) future, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Since: &tomorrow}) if err != nil || total != 0 || len(future) != 0 { t.Fatalf("фильтр «с завтра»: %d (total %d), err = %v", len(future), total, err) } yesterday := time.Now().UTC().AddDate(0, 0, -1) recent, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Since: &yesterday}) if err != nil || total != 4 { t.Fatalf("фильтр «с вчера»: %d (total %d), err = %v", len(recent), total, err) } // Пагинация: страницы не пересекаются, total не зависит от страницы. first, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Limit: 2}) if err != nil || len(first) != 2 || total != 4 { t.Fatalf("первая страница: %d (total %d), err = %v", len(first), total, err) } second, _, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Limit: 2, Offset: 3}) if err != nil || len(second) != 1 { t.Fatalf("вторая страница: %d, err = %v", len(second), err) } if first[0].ID == second[0].ID { t.Fatal("страницы журнала пересекаются") } actions, err := st.ListInstanceAuditActions(ctx) if err != nil { t.Fatalf("список действий: %v", err) } if len(actions) != 4 || actions[0] != "instance.settings_update" || actions[3] != "user.passkey_add" { t.Fatalf("действия журнала = %v", actions) } // История по пользователю: он цель в двух записях. userEntries, userTotal, err := st.ListUserAudit(ctx, target.ID, 10, 0) if err != nil || userTotal != 2 || len(userEntries) != 2 { t.Fatalf("аудит пользователя: %d (total %d), err = %v", len(userEntries), userTotal, err) } } // TestInstanceGuildListFiltersAndMemberships: список серверов панели // фильтруется и листается, а карточка участника показывает роли (AGENT.md 7.18). func TestInstanceGuildListFiltersAndMemberships(t *testing.T) { ctx := context.Background() st := newInstanceStore(t) owner := createInstanceUser(t, st, "list_owner") other := createInstanceUser(t, st, "list_other") first, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Альфа", OwnerID: owner.ID}) if err != nil { t.Fatalf("создать сервер: %v", err) } second, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Бета", OwnerID: other.ID}) if err != nil { t.Fatalf("создать сервер: %v", err) } if _, err := st.AddGuildMember(ctx, second.ID, other.ID, ""); err != nil { t.Fatalf("добавить владельца: %v", err) } main, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Главный", OwnerID: owner.ID, IsMain: true}) if err != nil { t.Fatalf("создать главный сервер: %v", err) } if _, err := st.AddGuildMember(ctx, first.ID, owner.ID, ""); err != nil { t.Fatalf("добавить владельца: %v", err) } if _, err := st.AddGuildMember(ctx, first.ID, other.ID, "гость"); err != nil { t.Fatalf("добавить участника: %v", err) } role, err := st.CreateRole(ctx, CreateRoleParams{ GuildID: first.ID, Name: "Модератор", Color: 0xFF0000, Position: 5, Permissions: 8, }) if err != nil { t.Fatalf("создать роль: %v", err) } if err := st.AssignRole(ctx, first.ID, other.ID, role.ID); err != nil { t.Fatalf("выдать роль: %v", err) } byName, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{Query: "альф"}) if err != nil || total != 1 || len(byName) != 1 { t.Fatalf("поиск по названию: %d (total %d), err = %v", len(byName), total, err) } if byName[0].MemberCount != 2 { t.Fatalf("участников в списке = %d, want 2", byName[0].MemberCount) } byOwner, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{OwnerID: owner.ID}) if err != nil || total != 2 { t.Fatalf("фильтр по владельцу: %d (total %d), err = %v", len(byOwner), total, err) } mainOnly, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{MainOnly: true}) if err != nil || total != 1 || mainOnly[0].ID != main.ID { t.Fatalf("главный сервер: %+v (total %d), err = %v", mainOnly, total, err) } page, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{Limit: 2, Offset: 2}) if err != nil || len(page) != 1 || total != 3 { t.Fatalf("страница серверов: %d (total %d), err = %v", len(page), total, err) } memberships, err := st.ListUserGuildMemberships(ctx, other.ID) if err != nil { t.Fatalf("серверы пользователя: %v", err) } if len(memberships) != 2 { t.Fatalf("серверов у пользователя = %d, want 2", len(memberships)) } found := false for _, membership := range memberships { if membership.GuildID != first.ID { continue } found = true if membership.Nickname != "гость" || len(membership.Roles) != 1 || membership.Roles[0].Name != "Модератор" { t.Fatalf("участие в сервере = %+v", membership) } } if !found { t.Fatal("сервер с ролью не найден в карточке пользователя") } } // TestUserSessionRevokeChecksOwner: отозвать можно только сессию самого // пользователя, чужая не удаляется (AGENT.md 7.18). func TestUserSessionRevokeChecksOwner(t *testing.T) { ctx := context.Background() st := newInstanceStore(t) owner := createInstanceUser(t, st, "session_owner") other := createInstanceUser(t, st, "session_other") session, err := st.CreateSession(ctx, owner.ID, CreateSessionParams{ TokenHash: "hash-session", UserAgent: "тест", IP: "127.0.0.1", }) if err != nil { t.Fatalf("создать сессию: %v", err) } if _, err := st.UserSession(ctx, other.ID, session.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("чужая сессия = %v, want ErrNotFound", err) } deleted, err := st.DeleteUserSession(ctx, other.ID, session.ID) if err != nil || deleted { t.Fatalf("чужое удаление: deleted = %v, err = %v", deleted, err) } if _, err := st.UserSession(ctx, owner.ID, session.ID); err != nil { t.Fatalf("своя сессия недоступна: %v", err) } deleted, err = st.DeleteUserSession(ctx, owner.ID, session.ID) if err != nil || !deleted { t.Fatalf("своё удаление: deleted = %v, err = %v", deleted, err) } if _, err := st.UserSession(ctx, owner.ID, session.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("сессия после отзыва = %v, want ErrNotFound", err) } }