f9464130cd
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).
Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.
Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.
Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
import type { Relationships } from '@/api/friends';
|
|
import {
|
|
findRequest,
|
|
installFetch,
|
|
installGatewaySocket,
|
|
json,
|
|
makeReadySnapshot,
|
|
makeUser,
|
|
renderApp,
|
|
type FetchRoute,
|
|
} from './helpers';
|
|
|
|
/**
|
|
* Групповые личные беседы (AGENT.md 7.8, Фаза 7): отображение в сайдбаре,
|
|
* создание из списка друзей и шапка беседы с числом участников.
|
|
*/
|
|
|
|
const user = makeUser();
|
|
|
|
const instance = {
|
|
instance: {
|
|
name: 'glchat-test',
|
|
registration_enabled: true,
|
|
allow_guild_creation: true,
|
|
max_guilds_per_user: 5,
|
|
main_guild_id: 'g-main',
|
|
},
|
|
};
|
|
|
|
const friends: Relationships = {
|
|
friends: [
|
|
{
|
|
user_id: 'user-2',
|
|
username: 'bob',
|
|
display_name: 'Боб',
|
|
status: 'online',
|
|
visible_status: 'online',
|
|
is_instance_admin: false,
|
|
badges: [],
|
|
timezone: 'Europe/Moscow',
|
|
relationship: 'friend',
|
|
},
|
|
{
|
|
user_id: 'user-3',
|
|
username: 'carol',
|
|
display_name: 'Кэрол',
|
|
status: 'offline',
|
|
visible_status: 'offline',
|
|
is_instance_admin: false,
|
|
badges: [],
|
|
timezone: 'Europe/Moscow',
|
|
relationship: 'friend',
|
|
},
|
|
],
|
|
incoming: [],
|
|
outgoing: [],
|
|
blocked: [],
|
|
};
|
|
|
|
/** Групповая беседа в REST-форме (`GET /users/@me/channels`). */
|
|
function groupChannel(id = 'dm-group-1', memberCount = 3) {
|
|
return {
|
|
id,
|
|
type: 'dm',
|
|
is_group: true,
|
|
name: 'Игровая',
|
|
member_count: memberCount,
|
|
owner_id: user.id,
|
|
can_send: true,
|
|
can_view: true,
|
|
recipients: [
|
|
{
|
|
user_id: user.id,
|
|
username: 'alice',
|
|
display_name: 'Alice',
|
|
status: 'online',
|
|
visible_status: 'online',
|
|
is_owner: true,
|
|
is_self: true,
|
|
},
|
|
{
|
|
user_id: 'user-2',
|
|
username: 'bob',
|
|
display_name: 'Боб',
|
|
status: 'online',
|
|
visible_status: 'online',
|
|
is_owner: false,
|
|
is_self: false,
|
|
},
|
|
{
|
|
user_id: 'user-3',
|
|
username: 'carol',
|
|
display_name: 'Кэрол',
|
|
status: 'offline',
|
|
visible_status: 'offline',
|
|
is_owner: false,
|
|
is_self: false,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function baseRoutes(channels: unknown[], extra: FetchRoute[] = []): FetchRoute[] {
|
|
return [
|
|
...extra,
|
|
{ match: '/api/v1/users/@me/relationships', response: () => json(friends) },
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
|
{ match: '/api/v1/users/@me/channels', response: () => json({ channels }) },
|
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
|
{ match: '/api/v1/instance', response: () => json(instance) },
|
|
];
|
|
}
|
|
|
|
describe('групповые беседы', () => {
|
|
it('группа показана в сайдбаре с числом участников и без точки статуса', async () => {
|
|
const channel = groupChannel();
|
|
installFetch(
|
|
baseRoutes(
|
|
[channel],
|
|
[
|
|
{
|
|
match: `/api/v1/channels/${channel.id}/messages`,
|
|
response: () => json({ messages: [] }),
|
|
},
|
|
{ match: `/api/v1/channels/${channel.id}/pins`, response: () => json({ messages: [] }) },
|
|
{
|
|
match: `/api/v1/channels/${channel.id}/ack`,
|
|
method: 'POST',
|
|
response: () => json({ ok: true }),
|
|
},
|
|
],
|
|
),
|
|
);
|
|
const gateway = installGatewaySocket(makeReadySnapshot());
|
|
|
|
// Сайдбар раздела «Друзья» живёт в оболочке приложения: открываем беседу,
|
|
// чтобы он отрисовался (как в реальном переходе).
|
|
renderApp(`/app/friends/${channel.id}`);
|
|
await gateway.greet();
|
|
|
|
const link = await screen.findByTestId('dm-link-dm-group-1');
|
|
expect(link).toHaveTextContent('Игровая');
|
|
expect(screen.getByTestId('dm-members-dm-group-1')).toHaveTextContent('3');
|
|
// У группы нет статуса собеседника: точки рядом с аватаром быть не должно.
|
|
expect(screen.queryByTestId('dm-status-dm-group-1')).toBeNull();
|
|
});
|
|
|
|
it('шапка групповой беседы показывает число участников', async () => {
|
|
const channel = groupChannel();
|
|
installFetch(
|
|
baseRoutes(
|
|
[channel],
|
|
[
|
|
{
|
|
match: `/api/v1/channels/${channel.id}/messages`,
|
|
response: () => json({ messages: [] }),
|
|
},
|
|
{ match: `/api/v1/channels/${channel.id}/pins`, response: () => json({ messages: [] }) },
|
|
{
|
|
match: `/api/v1/channels/${channel.id}/ack`,
|
|
method: 'POST',
|
|
response: () => json({ ok: true }),
|
|
},
|
|
],
|
|
),
|
|
);
|
|
const gateway = installGatewaySocket(makeReadySnapshot());
|
|
|
|
renderApp(`/app/friends/${channel.id}`);
|
|
await gateway.greet();
|
|
|
|
expect(await screen.findByTestId('dm-group-header')).toBeVisible();
|
|
expect(screen.getByTestId('dm-member-count')).toHaveTextContent('Участников: 3');
|
|
});
|
|
|
|
it('создаёт группу из двух друзей и открывает её', async () => {
|
|
const created = groupChannel('dm-group-2');
|
|
const fetchMock = installFetch(
|
|
baseRoutes(
|
|
[],
|
|
[
|
|
{
|
|
match: '/api/v1/users/@me/channels/group',
|
|
method: 'POST',
|
|
response: () => json({ channel: created }),
|
|
},
|
|
],
|
|
),
|
|
);
|
|
const gateway = installGatewaySocket(makeReadySnapshot());
|
|
const visitor = userEvent.setup();
|
|
|
|
const { router } = renderApp('/app/friends');
|
|
await gateway.greet();
|
|
await screen.findByTestId('dm-sidebar-empty');
|
|
|
|
await visitor.click(await screen.findByTestId('group-dm-open'));
|
|
await visitor.type(screen.getByLabelText('Название группы'), 'Игровая');
|
|
await visitor.click(screen.getByRole('checkbox', { name: 'Боб' }));
|
|
await visitor.click(screen.getByRole('checkbox', { name: 'Кэрол' }));
|
|
|
|
const submit = screen.getByRole('button', { name: 'Создать' });
|
|
// С одним участником группа не создаётся: кнопка включается со второго.
|
|
expect(submit).toBeEnabled();
|
|
await visitor.click(submit);
|
|
|
|
await waitFor(() => {
|
|
expect(findRequest(fetchMock, { url: '/channels/group', method: 'POST' })).toBeDefined();
|
|
});
|
|
const request = findRequest(fetchMock, { url: '/channels/group', method: 'POST' });
|
|
expect(request?.body).toEqual({
|
|
name: 'Игровая',
|
|
recipient_ids: ['user-2', 'user-3'],
|
|
});
|
|
await waitFor(() => {
|
|
expect(router.state.location.pathname).toBe('/app/friends/dm-group-2');
|
|
});
|
|
// Беседа сразу попала в список — отдельный запрос не нужен.
|
|
expect(await screen.findByTestId('dm-link-dm-group-2')).toBeVisible();
|
|
});
|
|
|
|
it('без двух выбранных друзей кнопка создания выключена', async () => {
|
|
installFetch(baseRoutes([]));
|
|
const gateway = installGatewaySocket(makeReadySnapshot());
|
|
const visitor = userEvent.setup();
|
|
|
|
renderApp('/app/friends');
|
|
await gateway.greet();
|
|
await visitor.click(await screen.findByTestId('group-dm-open'));
|
|
await visitor.click(screen.getByRole('checkbox', { name: 'Боб' }));
|
|
|
|
expect(screen.getByRole('button', { name: 'Создать' })).toBeDisabled();
|
|
});
|
|
});
|