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, requestUrl, type FetchRoute, } from './helpers'; /** * Иконка групповой беседы (AGENT.md 7.8, D-070): показ в списке бесед и в * шапке, меню владельца (загрузить/удалить) и обновление без перезагрузки. */ 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', }, ], incoming: [], outgoing: [], blocked: [], }; /** Групповая беседа в REST-форме: владелец и иконка настраиваются. */ function groupChannel(options: { ownerId?: string; iconFileId?: string } = {}) { const channel: Record = { id: 'dm-group-1', type: 'dm', is_group: true, name: 'Игровая', member_count: 3, owner_id: options.ownerId ?? 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: options.ownerId === undefined, 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, }, ], }; if (options.iconFileId !== undefined) { channel['icon_file_id'] = options.iconFileId; } return channel; } function imageFile(): File { const file = new File(['картинка'], 'icon.png', { type: 'image/png' }); Object.defineProperty(file, 'size', { value: 1024 }); return file; } /** * Маршруты приложения: список бесед отдаётся из изменяемого состояния — так * тест видит, что сервер уже принял новую иконку. */ function dmRoutes(current: () => 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/channels/dm-group-1/messages', response: () => json({ messages: [] }), }, { match: '/api/v1/channels/dm-group-1/pins', response: () => json({ messages: [] }) }, { match: '/api/v1/channels/dm-group-1/ack', method: 'POST', response: () => json({ ok: true }), }, { match: '/api/v1/users/@me/channels', response: () => json({ channels: current() }) }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/instance', response: () => json(instance) }, ]; } /** Открывает беседу и ждёт её отрисовки в сайдбаре. */ async function openGroup(channelId = 'dm-group-1') { renderApp(`/app/friends/${channelId}`); const gateway = installGatewaySocket(makeReadySnapshot()); await gateway.greet(); await screen.findByTestId('dm-group-header'); return gateway; } describe('иконка групповой беседы', () => { it('иконка группы видна в списке бесед и в шапке', async () => { const channels = [groupChannel({ iconFileId: '777' })]; installFetch(dmRoutes(() => channels)); await openGroup(); const link = await screen.findByTestId('dm-link-dm-group-1'); await waitFor(() => { expect(link.querySelector('img')).toHaveAttribute('src', '/files/777'); }); expect(screen.getByTestId('dm-group-header').querySelector('img')).toHaveAttribute( 'src', '/files/777', ); }); it('меню владельца с иконкой есть у владельца и отсутствует у участника', async () => { const channels = [groupChannel({ iconFileId: '777' })]; installFetch(dmRoutes(() => channels)); await openGroup(); const menu = await screen.findByTestId('dm-owner-menu'); await userEvent.click(menu); expect(await screen.findByTestId('dm-icon-upload')).toBeVisible(); // Иконка уже есть: пункт удаления тоже доступен владельцу. expect(screen.getByTestId('dm-icon-remove')).toBeVisible(); }); it('участник-не-владелец не видит меню владельца, но видит саму иконку', async () => { // Владелец беседы — Боб, текущий пользователь (Alice) только участник. const channels = [groupChannel({ ownerId: 'user-2', iconFileId: '777' })]; installFetch(dmRoutes(() => channels)); await openGroup(); expect(screen.queryByTestId('dm-owner-menu')).toBeNull(); expect(screen.getByTestId('dm-group-header').querySelector('img')).toHaveAttribute( 'src', '/files/777', ); }); it('владелец загружает иконку: список и шапка обновляются сразу', async () => { let channels = [groupChannel()]; const fetchMock = installFetch( dmRoutes( () => channels, [ { match: '/api/v1/channels/dm-group-1/icon', method: 'POST', response: () => { const updated = groupChannel({ iconFileId: '888' }); channels = [updated]; return json({ channel: updated }); }, }, ], ), ); await openGroup(); // До загрузки иконки нет: аватар рисуется инициалами. const link = await screen.findByTestId('dm-link-dm-group-1'); expect(link.querySelector('img')).toBeNull(); await userEvent.click(await screen.findByTestId('dm-owner-menu')); await userEvent.click(screen.getByTestId('dm-icon-upload')); // Диалог выбора файла в jsdom не открывается: файл кладём в поле напрямую. await userEvent.upload(screen.getByTestId('dm-icon-input'), imageFile()); await waitFor(() => { expect( findRequest(fetchMock, { url: '/channels/dm-group-1/icon', method: 'POST' }), ).toBeDefined(); }); // Файл уходит multipart-телом. const call = fetchMock.mock.calls.find( ([input, init]) => requestUrl(input).includes('/channels/dm-group-1/icon') && (init?.method ?? 'GET').toUpperCase() === 'POST', ); expect(call?.[1]?.body).toBeInstanceOf(FormData); await waitFor(() => { expect(link.querySelector('img')).toHaveAttribute('src', '/files/888'); }); expect(screen.getByTestId('dm-group-header').querySelector('img')).toHaveAttribute( 'src', '/files/888', ); }); it('владелец удаляет иконку: она пропадает из списка и шапки', async () => { let channels = [groupChannel({ iconFileId: '777' })]; const fetchMock = installFetch( dmRoutes( () => channels, [ { match: '/api/v1/channels/dm-group-1/icon', method: 'DELETE', response: () => { const updated = groupChannel(); channels = [updated]; return json({ channel: updated }); }, }, ], ), ); await openGroup(); const link = await screen.findByTestId('dm-link-dm-group-1'); await waitFor(() => { expect(link.querySelector('img')).toHaveAttribute('src', '/files/777'); }); await userEvent.click(await screen.findByTestId('dm-owner-menu')); await userEvent.click(screen.getByTestId('dm-icon-remove')); await waitFor(() => { expect( findRequest(fetchMock, { url: '/channels/dm-group-1/icon', method: 'DELETE' }), ).toBeDefined(); }); await waitFor(() => { expect(link.querySelector('img')).toBeNull(); }); expect(screen.getByTestId('dm-group-header').querySelector('img')).toBeNull(); }); it('событие DM_CHANNEL_UPDATE доносит новую иконку без перезагрузки', async () => { let channels = [groupChannel()]; installFetch(dmRoutes(() => channels)); const gateway = await openGroup(); const link = await screen.findByTestId('dm-link-dm-group-1'); expect(link.querySelector('img')).toBeNull(); // Иконку поставил владелец (возможно, с другого устройства): сервер // сообщает об изменении беседы событием (AGENT.md 11.6, D-070). channels = [groupChannel({ iconFileId: '999' })]; gateway.dispatch('DM_CHANNEL_UPDATE', { channel_id: 'dm-group-1' }); await waitFor(() => { expect(link.querySelector('img')).toHaveAttribute('src', '/files/999'); }); expect(screen.getByTestId('dm-group-header').querySelector('img')).toHaveAttribute( 'src', '/files/999', ); }); });