fix(realtime): события ролей, комнат и отзыв сессий без перезагрузки
Дефекты, найденные e2e-матрицей AGENT.md 11.6 (пункты 1, 2, 7, 12):
- ROLE_CREATE/ROLE_UPDATE уходили телом роли без guild_id, а клиент ждал
ссылку {guild_id, role_id} — событие отбрасывалось, роли у второго
участника не обновлялись. В rolePayload добавлено guild_id, parseRoleEvent
принимает и тело роли, и старую ссылку;
- выдача роли (MEMBER_UPDATE) и правки ролей не перечитывали список комнат,
поэтому приватная комната не появлялась в сайдбаре без F5 — теперь клиент
перечитывает комнаты по этим событиям;
- CHANNEL_CREATE/CHANNEL_UPDATE уходили телом комнаты с нулевым can_view
(права персональные), и клиент прятал комнату по этому признаку. Сервер
шлёт тело события без вычисленных прав плюс GUILD_CHANNELS_SYNC, а клиент
по событию комнаты перечитывает список;
- отзыв сессий (logout-all, смена пароля, админский сброс и «выйти везде»,
бан, удаление) не доходил до Gateway: второе устройство оставалось в
приложении. Добавлен gateway.InvalidateUser — клиент получает
INVALID_SESSION и закрывает соединение; клиент дополнительно обрабатывает
событие SESSION_INVALIDATED.
Тесты: gateway (отзыв сессий закрывает соединения и не задевает чужого),
web (событие SESSION_INVALIDATED, перечитывание комнат по CHANNEL_CREATE).
This commit is contained in:
@@ -391,6 +391,26 @@ func (s *Service) SendToUser(userID uint64, event string, payload any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateUser закрывает все соединения пользователя: клиент получает
|
||||||
|
// INVALID_SESSION и уходит на экран входа. Нужно там, где сессии отозваны
|
||||||
|
// (logout-all, смена пароля, действия администратора) — иначе второе
|
||||||
|
// устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6).
|
||||||
|
func (s *Service) InvalidateUser(userID uint64, reason string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
targets := make([]*clientSession, 0, 2)
|
||||||
|
for _, session := range s.sessions {
|
||||||
|
if session.userID == userID {
|
||||||
|
targets = append(targets, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, session := range targets {
|
||||||
|
// Сначала кадр (writeSync дожидается записи), потом закрытие.
|
||||||
|
s.sendInvalidSession(session, reason)
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func frame(op int, payload any) ([]byte, error) {
|
func frame(op int, payload any) ([]byte, error) {
|
||||||
var encoded json.RawMessage
|
var encoded json.RawMessage
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
|
|||||||
@@ -404,3 +404,42 @@ func TestResumeReplaysMissedEvents(t *testing.T) {
|
|||||||
t.Fatalf("stale resume = %q, want full READY", full.T)
|
t.Fatalf("stale resume = %q, want full READY", full.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInvalidateUserClosesConnections проверяет отзыв сессий: после
|
||||||
|
// logout-all, смены пароля или действий администратора соединения клиента
|
||||||
|
// должны закрыться с INVALID_SESSION, а не ждать следующего запроса
|
||||||
|
// (AGENT.md 11.6). Отдельно проверяется, что чужого пользователя это не задевает.
|
||||||
|
func TestInvalidateUserClosesConnections(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
token := registerUser(t, f, "invalidate_user", "invalidate@example.com")
|
||||||
|
otherToken := registerUser(t, f, "invalidate_other", "invalidate-other@example.com")
|
||||||
|
|
||||||
|
conn, _ := f.dial(t)
|
||||||
|
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
|
||||||
|
if ready := readEnvelope(t, conn); ready.T != "READY" {
|
||||||
|
t.Fatalf("expected READY, got %q", ready.T)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherConn, _ := f.dial(t)
|
||||||
|
send(t, otherConn, gateway.OpIdentify, map[string]any{"token": otherToken})
|
||||||
|
if ready := readEnvelope(t, otherConn); ready.T != "READY" {
|
||||||
|
t.Fatalf("expected READY for the other client, got %q", ready.T)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := f.auth.UserByEmail(context.Background(), "invalidate@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
f.service.InvalidateUser(user.ID, "logout_all")
|
||||||
|
|
||||||
|
invalid := readEnvelope(t, conn)
|
||||||
|
if invalid.Op != gateway.OpInvalidSess {
|
||||||
|
t.Fatalf("op = %d, want INVALID_SESSION (%d)", invalid.Op, gateway.OpInvalidSess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй пользователь продолжает работать: heartbeat отвечает.
|
||||||
|
send(t, otherConn, gateway.OpHeartbeat, nil)
|
||||||
|
if ack := readEnvelope(t, otherConn); ack.Op != gateway.OpHeartbeatAck {
|
||||||
|
t.Fatalf("other client op = %d, want HEARTBEAT_ACK", ack.Op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ func channelOverwritePayloads(overrides []store.ChannelOverride) []channelOverwr
|
|||||||
|
|
||||||
type rolePayload struct {
|
type rolePayload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
// GuildID нужен событиям ROLE_CREATE/ROLE_UPDATE: в REST он избыточен
|
||||||
|
// (сервер и так в пути), а в кадре шлюза без него роль некуда отнести.
|
||||||
|
GuildID string `json:"guild_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color int64 `json:"color"`
|
Color int64 `json:"color"`
|
||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
@@ -110,6 +113,14 @@ type guildDetailPayload struct {
|
|||||||
Channels []channelPayload `json:"channels,omitempty"`
|
Channels []channelPayload `json:"channels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// channelEventPayload собирает тело комнаты для события шлюза: права в нём
|
||||||
|
// не вычислены (они персональные), поэтому признаки can_* не выставляются —
|
||||||
|
// клиент по этому событию перечитывает список комнат, а не прячет комнату по
|
||||||
|
// нулевому can_view.
|
||||||
|
func channelEventPayload(channel store.Channel) channelPayload {
|
||||||
|
return channelFromStore(channel, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func channelFromStore(channel store.Channel, resolved *permissions.Resolved, overrides []store.ChannelOverride) channelPayload {
|
func channelFromStore(channel store.Channel, resolved *permissions.Resolved, overrides []store.ChannelOverride) channelPayload {
|
||||||
payload := channelPayload{
|
payload := channelPayload{
|
||||||
ID: formatSnowflake(channel.ID),
|
ID: formatSnowflake(channel.ID),
|
||||||
@@ -141,6 +152,7 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved, ove
|
|||||||
func roleFromStore(role store.Role) rolePayload {
|
func roleFromStore(role store.Role) rolePayload {
|
||||||
payload := rolePayload{
|
payload := rolePayload{
|
||||||
ID: formatSnowflake(role.ID),
|
ID: formatSnowflake(role.ID),
|
||||||
|
GuildID: formatSnowflake(role.GuildID),
|
||||||
Name: role.Name,
|
Name: role.Name,
|
||||||
Color: role.Color,
|
Color: role.Color,
|
||||||
Position: role.Position,
|
Position: role.Position,
|
||||||
@@ -585,7 +597,12 @@ func (s *Server) registerChannelRoutes(api huma.API) {
|
|||||||
"type": string(channel.Type),
|
"type": string(channel.Type),
|
||||||
})
|
})
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil, nil))
|
// Видимость комнаты считается персонально, поэтому вместо тела комнаты
|
||||||
|
// (в нём can_view был бы нулевым) просим клиентов перечитать список.
|
||||||
|
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelEventPayload(*channel))
|
||||||
|
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||||||
|
"guild_id": formatSnowflake(guildID),
|
||||||
|
})
|
||||||
// Права на новую комнату считаем для автора запроса.
|
// Права на новую комнату считаем для автора запроса.
|
||||||
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
|
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -642,7 +659,10 @@ func (s *Server) registerChannelRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
s.invalidateGuild(guildID)
|
s.invalidateGuild(guildID)
|
||||||
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelFromStore(*channel, nil, nil))
|
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelEventPayload(*channel))
|
||||||
|
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||||||
|
"guild_id": formatSnowflake(guildID),
|
||||||
|
})
|
||||||
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
|
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
@@ -822,6 +842,11 @@ func (s *Server) registerChannelRoutes(api huma.API) {
|
|||||||
"channel_id": formatSnowflake(channelID),
|
"channel_id": formatSnowflake(channelID),
|
||||||
"guild_id": formatSnowflake(guildID),
|
"guild_id": formatSnowflake(guildID),
|
||||||
})
|
})
|
||||||
|
// Удалили комнату — у части участников могли остаться права на неё,
|
||||||
|
// поэтому список перечитывают все (AGENT.md 11.6).
|
||||||
|
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
|
||||||
|
"guild_id": formatSnowflake(guildID),
|
||||||
|
})
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,6 +488,9 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
if err := s.auth.SetPassword(ctx, target.ID, password); err != nil {
|
if err := s.auth.SetPassword(ctx, target.ID, password); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.InvalidateUser(target.ID, "password_reset")
|
||||||
|
}
|
||||||
s.recordAudit(ctx, admin, 0, "instance.user_password_reset", "user", &target.ID, "")
|
s.recordAudit(ctx, admin, 0, "instance.user_password_reset", "user", &target.ID, "")
|
||||||
output := &adminPasswordOutput{}
|
output := &adminPasswordOutput{}
|
||||||
output.Body.UserID = formatSnowflake(target.ID)
|
output.Body.UserID = formatSnowflake(target.ID)
|
||||||
@@ -521,6 +524,9 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
if _, err := s.store.RevokeUserSessions(ctx, targetID); err != nil {
|
if _, err := s.store.RevokeUserSessions(ctx, targetID); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.InvalidateUser(targetID, "logout_all")
|
||||||
|
}
|
||||||
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
|
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
@@ -575,7 +581,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
if s.gateway != nil {
|
if s.gateway != nil {
|
||||||
s.gateway.SendToUser(target.ID, "SESSION_INVALIDATED", map[string]any{"reason": "user_banned"})
|
s.gateway.InvalidateUser(target.ID, "user_banned")
|
||||||
}
|
}
|
||||||
s.recordAudit(ctx, admin, 0, "instance.user_ban", "user", &target.ID, reason)
|
s.recordAudit(ctx, admin, 0, "instance.user_ban", "user", &target.ID, reason)
|
||||||
updated, err := s.store.GetUser(ctx, target.ID)
|
updated, err := s.store.GetUser(ctx, target.ID)
|
||||||
@@ -663,7 +669,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
|||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
if s.gateway != nil {
|
if s.gateway != nil {
|
||||||
s.gateway.SendToUser(targetID, "SESSION_INVALIDATED", map[string]any{"reason": "user_deleted"})
|
s.gateway.InvalidateUser(targetID, "user_deleted")
|
||||||
}
|
}
|
||||||
s.recordAudit(ctx, admin, 0, "instance.user_delete", "user", &targetID, "")
|
s.recordAudit(ctx, admin, 0, "instance.user_delete", "user", &targetID, "")
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
|
|||||||
@@ -270,6 +270,10 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
|||||||
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
// Смена пароля отзывает остальные сессии: соединения закрываем сразу.
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.InvalidateUser(user.ID, "password_changed")
|
||||||
|
}
|
||||||
return newOKOutput(), nil
|
return newOKOutput(), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,10 @@ func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeAPIError(w, err)
|
writeAPIError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Другие устройства должны узнать об отзыве сессий сразу (AGENT.md 11.6).
|
||||||
|
if s.gateway != nil {
|
||||||
|
s.gateway.InvalidateUser(user.ID, "logout_all")
|
||||||
|
}
|
||||||
http.SetCookie(w, s.clearSessionCookie())
|
http.SetCookie(w, s.clearSessionCookie())
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import reactRefresh from 'eslint-plugin-react-refresh';
|
|||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'coverage', 'node_modules'] },
|
// Артефакты Playwright (test-results*, отчёты) не линтуем: их пишет прогон.
|
||||||
|
{ ignores: ['dist', 'coverage', 'node_modules', 'test-results*', 'playwright-report'] },
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
||||||
|
|||||||
@@ -215,7 +215,9 @@ export function parseMemberRemove(value: unknown): MemberRemoveEvent | null {
|
|||||||
export function parseRoleEvent(value: unknown): RoleEvent | null {
|
export function parseRoleEvent(value: unknown): RoleEvent | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
const guildId = asString(record?.['guild_id']);
|
const guildId = asString(record?.['guild_id']);
|
||||||
const roleId = asString(record?.['role_id']);
|
// Сервер шлёт тело роли (поле id), старый формат — ссылку role_id:
|
||||||
|
// принимаем оба, иначе событие роли отбрасывалось и список не обновлялся.
|
||||||
|
const roleId = asString(record?.['role_id']) ?? asString(record?.['id']);
|
||||||
if (record === null || guildId === undefined || roleId === undefined) {
|
if (record === null || guildId === undefined || roleId === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ interface GatewayState {
|
|||||||
invalidated: boolean;
|
invalidated: boolean;
|
||||||
connect: (userId: string) => void;
|
connect: (userId: string) => void;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
|
/** Сессия отозвана сервером (logout-all, смена пароля, бан): экран входа. */
|
||||||
|
markInvalidated: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +109,10 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
|||||||
set({ ...defaults });
|
set({ ...defaults });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** markInvalidated — сессия отозвана сервером: показываем экран входа. */
|
||||||
|
markInvalidated: () => {
|
||||||
|
set({ invalidated: true, status: 'disconnected' });
|
||||||
|
},
|
||||||
reset: () => {
|
reset: () => {
|
||||||
disconnectGateway();
|
disconnectGateway();
|
||||||
set({ ...defaults });
|
set({ ...defaults });
|
||||||
@@ -199,7 +205,10 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
}
|
}
|
||||||
const guildId = channel.guild_id ?? session.selectedGuildId;
|
const guildId = channel.guild_id ?? session.selectedGuildId;
|
||||||
if (guildId !== null) {
|
if (guildId !== null) {
|
||||||
session.upsertChannel(guildId, channel);
|
// Права на комнату персональные: тело события их не несёт, поэтому
|
||||||
|
// перечитываем список — иначе новая или переименованная комната не
|
||||||
|
// появится в сайдбаре без перезагрузки (AGENT.md 11.6).
|
||||||
|
void session.reloadGuildChannels(guildId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -274,10 +283,12 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
}
|
}
|
||||||
case 'MEMBER_UPDATE':
|
case 'MEMBER_UPDATE':
|
||||||
case 'MEMBER_REMOVE': {
|
case 'MEMBER_REMOVE': {
|
||||||
// Роли участника влияют на права: перечитываем участников и карточку сервера.
|
// Роли участника влияют на права: перечитываем участников, карточку
|
||||||
|
// сервера и список комнат (приватные комнаты открываются ролью).
|
||||||
const guildId = parseGuildId(event.d);
|
const guildId = parseGuildId(event.d);
|
||||||
if (guildId !== null) {
|
if (guildId !== null) {
|
||||||
void invalidateMembershipQueries(guildId);
|
void invalidateMembershipQueries(guildId);
|
||||||
|
void session.reloadGuildChannels(guildId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -287,6 +298,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
const role = parseRoleEvent(event.d);
|
const role = parseRoleEvent(event.d);
|
||||||
if (role !== null) {
|
if (role !== null) {
|
||||||
void invalidateMembershipQueries(role.guild_id);
|
void invalidateMembershipQueries(role.guild_id);
|
||||||
|
void session.reloadGuildChannels(role.guild_id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -335,6 +347,16 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'SESSION_INVALIDATED': {
|
||||||
|
// Сервер отозвал сессии (logout-all, смена пароля, бан, удаление):
|
||||||
|
// уходим на экран входа, не дожидаясь следующего запроса (AGENT.md 11.6).
|
||||||
|
useGatewayStore.getState().markInvalidated();
|
||||||
|
session.reset();
|
||||||
|
messages.reset();
|
||||||
|
useVoiceConnectionStore.getState().reset();
|
||||||
|
void invalidateCurrentUser();
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'GUILD_CHANNELS_SYNC': {
|
case 'GUILD_CHANNELS_SYNC': {
|
||||||
// Права комнаты изменились: видимость может поменяться у всех
|
// Права комнаты изменились: видимость может поменяться у всех
|
||||||
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
|
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
|
||||||
|
|||||||
@@ -140,28 +140,49 @@ describe('диспетчер событий шлюза', () => {
|
|||||||
expect(state.selectedChannelId).toBeNull();
|
expect(state.selectedChannelId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CHANNEL_CREATE добавляет комнату, CHANNEL_UPDATE заменяет её', () => {
|
it('CHANNEL_CREATE и CHANNEL_UPDATE перечитывают комнаты с сервера', async () => {
|
||||||
|
// Права на комнату персональные: тело события их не несёт, поэтому клиент
|
||||||
|
// перечитывает список, а не подставляет комнату из кадра (AGENDA.md 11.6).
|
||||||
|
installFetch([
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/channels',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
channels: [
|
||||||
|
{ id: 'c-1', guild_id: 'g-1', name: 'общий', type: 'text', position: 0 },
|
||||||
|
{ id: 'c-2', guild_id: 'g-1', name: 'флуд', type: 'text', position: 1 },
|
||||||
|
{
|
||||||
|
id: 'c-3',
|
||||||
|
guild_id: 'g-1',
|
||||||
|
name: 'новости',
|
||||||
|
type: 'text',
|
||||||
|
position: 2,
|
||||||
|
can_send: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
seedSession();
|
seedSession();
|
||||||
|
|
||||||
dispatchGatewayEvent({
|
dispatchGatewayEvent({
|
||||||
op: 0,
|
op: 0,
|
||||||
t: 'CHANNEL_CREATE',
|
t: 'CHANNEL_CREATE',
|
||||||
s: 8,
|
s: 8,
|
||||||
d: {
|
d: { id: 'c-3', guild_id: 'g-1', name: 'анонсы', type: 'text', position: 2 },
|
||||||
id: 'c-3',
|
|
||||||
guild_id: 'g-1',
|
|
||||||
name: 'анонсы',
|
|
||||||
type: 'text',
|
|
||||||
position: 2,
|
|
||||||
can_send: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const channels = useSessionStore
|
||||||
|
.getState()
|
||||||
|
.guilds.find((guild) => guild.id === 'g-1')
|
||||||
|
?.channels.map((channel) => channel.name);
|
||||||
|
expect(channels).toEqual(['общий', 'флуд', 'новости']);
|
||||||
|
});
|
||||||
const created = useSessionStore
|
const created = useSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.guilds.find((guild) => guild.id === 'g-1')
|
.guilds.find((guild) => guild.id === 'g-1')
|
||||||
?.channels.find((channel) => channel.id === 'c-3');
|
?.channels.find((channel) => channel.id === 'c-3');
|
||||||
expect(created?.name).toBe('анонсы');
|
|
||||||
expect(created?.can_send).toBe(false);
|
expect(created?.can_send).toBe(false);
|
||||||
|
|
||||||
dispatchGatewayEvent({
|
dispatchGatewayEvent({
|
||||||
@@ -170,13 +191,13 @@ describe('диспетчер событий шлюза', () => {
|
|||||||
s: 9,
|
s: 9,
|
||||||
d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 },
|
d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 },
|
||||||
});
|
});
|
||||||
|
await waitFor(() => {
|
||||||
const updated = useSessionStore
|
const updated = useSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.guilds.find((guild) => guild.id === 'g-1')
|
.guilds.find((guild) => guild.id === 'g-1')
|
||||||
?.channels.filter((channel) => channel.id === 'c-3');
|
?.channels.filter((channel) => channel.id === 'c-3');
|
||||||
expect(updated).toHaveLength(1);
|
expect(updated).toHaveLength(1);
|
||||||
expect(updated?.[0]?.name).toBe('новости');
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CHANNEL_DELETE удаляет комнату и переносит выбор на первую доступную', () => {
|
it('CHANNEL_DELETE удаляет комнату и переносит выбор на первую доступную', () => {
|
||||||
@@ -197,6 +218,24 @@ describe('диспетчер событий шлюза', () => {
|
|||||||
expect(state.selectedChannelId).toBe('c-2');
|
expect(state.selectedChannelId).toBe('c-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('SESSION_INVALIDATED переводит клиент на экран входа', () => {
|
||||||
|
// Сервер отзывает сессии при logout-all, смене пароля и бане: второе
|
||||||
|
// устройство не должно оставаться в приложении до следующего запроса.
|
||||||
|
seedSession();
|
||||||
|
useGatewayStore.setState({ invalidated: false, status: 'connected' });
|
||||||
|
|
||||||
|
dispatchGatewayEvent({
|
||||||
|
op: 0,
|
||||||
|
t: 'SESSION_INVALIDATED',
|
||||||
|
s: 42,
|
||||||
|
d: { reason: 'logout_all' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useGatewayStore.getState().invalidated).toBe(true);
|
||||||
|
expect(useGatewayStore.getState().status).toBe('disconnected');
|
||||||
|
expect(useSessionStore.getState().guilds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('MEMBER_UPDATE с ролями инвалидирует участников, роли и карточку сервера', async () => {
|
it('MEMBER_UPDATE с ролями инвалидирует участников, роли и карточку сервера', async () => {
|
||||||
const { client, invalidated } = makeRecordingClient();
|
const { client, invalidated } = makeRecordingClient();
|
||||||
client.setQueryData(guildMembersQueryKey('g-1'), []);
|
client.setQueryData(guildMembersQueryKey('g-1'), []);
|
||||||
|
|||||||
Reference in New Issue
Block a user