import { describe, expect, it } from 'vitest'; import { enableTotp, login, logoutAll, register, setupTotp, stepUp } from '@/api/auth'; import { createChannel, fetchChannels, joinGuild, leaveGuild } from '@/api/guilds'; import { fetchInstance, fetchAudit } from '@/api/instance'; import { fetchMyGuilds, updateProfile } from '@/api/users'; import { ApiError } from '@/api/client'; import { findRequest, installFetch, json, recordedRequests } from './helpers'; function lastCall(fetchMock: ReturnType) { const request = recordedRequests(fetchMock).at(-1); if (request === undefined) { throw new Error('fetch не вызывался'); } return request; } describe('api-модули Фазы 1', () => { it('register/login отправляют POST с телом и читают поле user', async () => { const fetchMock = installFetch([ { match: '/api/v1/auth/register', method: 'POST', response: () => json({ user: { id: 'u' } }), }, ]); await expect( register({ username: 'alice', display_name: 'Alice', email: 'a@b.co', password: 'password12', locale: 'ru', }), ).resolves.toEqual({ user: { id: 'u' } }); expect(findRequest(fetchMock, { url: '/auth/register', method: 'POST' })?.method).toBe('POST'); }); it('login отправляет POST с телом', async () => { const fetchMock = installFetch([ { match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) }, ]); await login({ email: 'a@b.co', password: 'password12' }); expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({ email: 'a@b.co', password: 'password12', }); }); it('login прокидывает код 2FA как totp_code', async () => { const fetchMock = installFetch([ { match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) }, ]); await login({ email: 'a@b.co', password: 'password12', totp_code: '123456' }); expect(lastCall(fetchMock).body).toEqual({ email: 'a@b.co', password: 'password12', totp_code: '123456', }); }); it('setup/enable 2FA и step-up используют нужные пути', async () => { const fetchMock = installFetch([ { match: '/api/v1/auth/2fa/setup', method: 'POST', response: () => json({ secret: 'SECRET', otpauth_url: 'otpauth://x' }), }, { match: '/api/v1/auth/2fa/enable', method: 'POST', response: () => json({ recovery_codes: ['one', 'two'] }), }, { match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) }, { match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) }, ]); await expect(setupTotp()).resolves.toEqual({ secret: 'SECRET', otpauth_url: 'otpauth://x' }); await expect(enableTotp('123456')).resolves.toEqual(['one', 'two']); await stepUp('password12', '654321'); expect(lastCall(fetchMock).body).toEqual({ password: 'password12', totp_code: '654321', }); await expect(logoutAll()).resolves.toEqual({ ok: true }); }); it('PATCH профиля уходит на /users/@me и возвращает user', async () => { const fetchMock = installFetch([ { match: '/api/v1/users/@me', method: 'PATCH', response: () => json({ user: { id: 'u', display_name: 'Новое' } }), }, ]); await expect(updateProfile({ display_name: 'Новое' })).resolves.toMatchObject({ display_name: 'Новое', }); expect(lastCall(fetchMock).method).toBe('PATCH'); }); it('GET /users/@me/guilds разворачивает конверт {guilds}', async () => { installFetch([ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [{ id: 'g-1' }] }) }, ]); await expect(fetchMyGuilds()).resolves.toEqual([{ id: 'g-1' }]); }); it('каналы и участие в сервере используют id в пути', async () => { const fetchMock = installFetch([ { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [{ id: 'c-1' }] }) }, { match: '/api/v1/guilds/g-1/join', method: 'POST', response: () => json({ ok: true }) }, { match: '/api/v1/guilds/g-1/leave', method: 'POST', response: () => json({ ok: true }) }, ]); await expect(fetchChannels('g-1')).resolves.toEqual([{ id: 'c-1' }]); await expect(joinGuild('g-1')).resolves.toEqual({ ok: true }); await expect(leaveGuild('g-1')).resolves.toEqual({ ok: true }); expect(lastCall(fetchMock).url).toContain('/api/v1/guilds/g-1/leave'); }); it('createChannel отправляет type и parent_id', async () => { const fetchMock = installFetch([ { match: '/api/v1/guilds/g-1/channels', method: 'POST', response: () => json({ channel: { id: 'c-9' } }), }, ]); await createChannel('g-1', { name: 'общий', type: 'text', parent_id: 'cat-1' }); expect(lastCall(fetchMock).body).toEqual({ name: 'общий', type: 'text', parent_id: 'cat-1', }); }); it('instance: конверты {instance}, {settings}, {entries} и лимит аудита', async () => { const fetchMock = installFetch([ { match: '/api/v1/instance/settings', response: () => json({ settings: { motd: 'x' } }) }, { match: '/api/v1/instance/audit', response: () => json({ entries: [{ id: 'a-1' }] }) }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat' } }) }, ]); await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' }); // Журнал отдаётся вместе с общим числом записей под фильтр (AGENT.md 7.18). await expect(fetchAudit({ limit: 50 })).resolves.toEqual({ entries: [{ id: 'a-1' }], total: 1, }); expect(recordedRequests(fetchMock).map((request) => request.url)).toContain( '/api/v1/instance/audit?limit=50', ); }); it('пробрасывает коды ошибок контракта (в том числе auth.guild_limit_reached)', async () => { installFetch([ { match: '/api/v1/guilds', method: 'POST', response: () => json({ error: { code: 'auth.guild_limit_reached', message: 'limit' } }, 403), }, ]); const error = await import('@/api/guilds') .then(({ createGuild }) => createGuild('Много')) .catch((reason: unknown) => reason); expect(error).toBeInstanceOf(ApiError); expect((error as ApiError).code).toBe('auth.guild_limit_reached'); expect((error as ApiError).status).toBe(403); }); });