diff --git a/apps/api/test/e2e/team-members.e2e-spec.ts b/apps/api/test/e2e/team-members.e2e-spec.ts new file mode 100644 index 00000000..4101964b --- /dev/null +++ b/apps/api/test/e2e/team-members.e2e-spec.ts @@ -0,0 +1,217 @@ +import type { Server } from 'http' +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { INestApplication } from '@nestjs/common' +import request from 'supertest' +import type { Redis } from 'ioredis' + +import { PrismaService } from '../../prisma/prisma.service' +import { createTestApp, registerAndLogin } from '../helpers/e2e.helpers' + +describe('TeamMembers (e2e)', () => { + let app: INestApplication + let server: Server + let prisma: PrismaService + let redisClient: Redis + + let ownerToken: string + let adminToken: string + let memberToken: string + let strangerToken: string + + let ownerId: string + let adminId: string + let memberId: string + let strangerId: string + + let teamId: string + + beforeAll(async () => { + const testApp = await createTestApp() + app = testApp.app + server = app.getHttpServer() as Server + prisma = testApp.prisma + redisClient = testApp.redisClient + }) + + afterAll(async () => { + await redisClient.flushall() + await prisma.$disconnect() + await app.close() + }) + + beforeEach(async () => { + await prisma.teamMember.deleteMany() + await prisma.team.deleteMany() + await prisma.user.deleteMany() + await redisClient.flushall() + + // Создаём 4 пользователей: owner, admin, member, stranger (не в команде) + const ownerAuth = await registerAndLogin(app, 'owner@test.com') + const adminAuth = await registerAndLogin(app, 'admin@test.com') + const memberAuth = await registerAndLogin(app, 'member@test.com') + const strangerAuth = await registerAndLogin(app, 'stranger@test.com') + + ownerToken = ownerAuth.accessToken + adminToken = adminAuth.accessToken + memberToken = memberAuth.accessToken + strangerToken = strangerAuth.accessToken + + // Создаём команду от имени owner + const createRes = await request(server) + .post('/teams/new') + .set('Authorization', `Bearer ${ownerToken}`) + .send({ name: 'Test Team' }) + .expect(201) + + teamId = createRes.body.id as string + + // Получаем userId каждого пользователя из БД + const [ownerUser, adminUser, memberUser, strangerUser] = await Promise.all([ + prisma.user.findUnique({ where: { email: 'owner@test.com' } }), + prisma.user.findUnique({ where: { email: 'admin@test.com' } }), + prisma.user.findUnique({ where: { email: 'member@test.com' } }), + prisma.user.findUnique({ where: { email: 'stranger@test.com' } }), + ]) + + ownerId = ownerUser!.id + adminId = adminUser!.id + memberId = memberUser!.id + strangerId = strangerUser!.id + + // Добавляем admin и member в команду напрямую через призму + await prisma.teamMember.createMany({ + data: [ + { teamId, userId: adminId, role: 'ADMIN' }, + { teamId, userId: memberId, role: 'MEMBER' }, + ], + }) + }) + + // ── GET /teams/:id/members ──────────────────────────────────────────────── + describe('GET /teams/:id/members', () => { + it('должен вернуть 200 и список участников для члена команды', async () => { + const res = await request(server) + .get(`/teams/${teamId}/members`) + .set('Authorization', `Bearer ${ownerToken}`) + .expect(200) + + expect(res.body).toHaveLength(3) + expect(res.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: ownerId, role: 'OWNER' }), + expect.objectContaining({ userId: adminId, role: 'ADMIN' }), + expect.objectContaining({ userId: memberId, role: 'MEMBER' }), + ]), + ) + }) + + it('должен вернуть 401 без токена', async () => { + await request(server).get(`/teams/${teamId}/members`).expect(401) + }) + + it('должен вернуть 403 если пользователь не в команде', async () => { + await request(server) + .get(`/teams/${teamId}/members`) + .set('Authorization', `Bearer ${strangerToken}`) + .expect(403) + }) + + it('должен вернуть 404 если команда не найдена', async () => { + await request(server) + .get('/teams/00000000-0000-0000-0000-000000000000/members') + .set('Authorization', `Bearer ${ownerToken}`) + .expect(404) + }) + }) + + // ── PATCH /teams/:id/members/:userId/role ───────────────────────────────── + describe('PATCH /teams/:id/members/:userId/role', () => { + it('должен вернуть 200 когда OWNER меняет роль MEMBER на ADMIN', async () => { + const res = await request(server) + .patch(`/teams/${teamId}/members/${memberId}/role`) + .set('Authorization', `Bearer ${ownerToken}`) + .send({ role: 'ADMIN' }) + .expect(200) + + expect(res.body).toMatchObject({ userId: memberId, role: 'ADMIN' }) + }) + + it('должен вернуть 403 если MEMBER пытается изменить роль', async () => { + await request(server) + .patch(`/teams/${teamId}/members/${adminId}/role`) + .set('Authorization', `Bearer ${memberToken}`) + .send({ role: 'MEMBER' }) + .expect(403) + }) + + it('должен вернуть 403 если пытаются изменить свою собственную роль', async () => { + await request(server) + .patch(`/teams/${teamId}/members/${ownerId}/role`) + .set('Authorization', `Bearer ${ownerToken}`) + .send({ role: 'ADMIN' }) + .expect(403) + }) + + it('должен вернуть 403 при попытке изменить роль OWNER', async () => { + await request(server) + .patch(`/teams/${teamId}/members/${ownerId}/role`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ role: 'MEMBER' }) + .expect(403) + }) + + it('должен вернуть 404 если целевой участник не найден в команде', async () => { + await request(server) + .patch(`/teams/${teamId}/members/${strangerId}/role`) + .set('Authorization', `Bearer ${ownerToken}`) + .send({ role: 'MEMBER' }) + .expect(404) + }) + + it('должен вернуть 401 без токена', async () => { + await request(server) + .patch(`/teams/${teamId}/members/${memberId}/role`) + .send({ role: 'ADMIN' }) + .expect(401) + }) + }) + + // ── DELETE /teams/:id/members/:userId ───────────────────────────────────── + describe('DELETE /teams/:id/members/:userId', () => { + it('должен вернуть 200 когда OWNER удаляет MEMBER', async () => { + const res = await request(server) + .delete(`/teams/${teamId}/members/${memberId}`) + .set('Authorization', `Bearer ${ownerToken}`) + .expect(200) + + expect(res.body).toEqual({ message: 'Участник успешно исключён', success: true }) + }) + + it('должен вернуть 200 при самоуходе участника (self-leave)', async () => { + const res = await request(server) + .delete(`/teams/${teamId}/members/${memberId}`) + .set('Authorization', `Bearer ${memberToken}`) + .expect(200) + + expect(res.body).toEqual({ message: 'Вы покинули команду', success: true }) + }) + + it('должен вернуть 403 если MEMBER пытается удалить другого участника', async () => { + await request(server) + .delete(`/teams/${teamId}/members/${adminId}`) + .set('Authorization', `Bearer ${memberToken}`) + .expect(403) + }) + + it('должен вернуть 403 при попытке удалить OWNER', async () => { + await request(server) + .delete(`/teams/${teamId}/members/${ownerId}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(403) + }) + + it('должен вернуть 401 без токена', async () => { + await request(server).delete(`/teams/${teamId}/members/${memberId}`).expect(401) + }) + }) +}) diff --git a/apps/api/test/helpers/teams.helpers.ts b/apps/api/test/helpers/teams.helpers.ts index fb4bfc46..4c093d76 100644 --- a/apps/api/test/helpers/teams.helpers.ts +++ b/apps/api/test/helpers/teams.helpers.ts @@ -12,6 +12,8 @@ export function createPrismaMock() { teamMember: { findUnique: vi.fn(), findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), }, } as unknown as PrismaService & { team: { @@ -23,6 +25,8 @@ export function createPrismaMock() { teamMember: { findUnique: ReturnType findMany: ReturnType + update: ReturnType + delete: ReturnType } } } diff --git a/apps/api/test/unit/teams/team-members.service.spec.ts b/apps/api/test/unit/teams/team-members.service.spec.ts new file mode 100644 index 00000000..534f0c60 --- /dev/null +++ b/apps/api/test/unit/teams/team-members.service.spec.ts @@ -0,0 +1,259 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { TeamMembersService } from '../../../src/teams/members/team-members.service' +import { + createPrismaMock, + USER_ID, + TEAM_ID, + MEMBER_OWNER, + MEMBER_ADMIN, + MEMBER_PLAIN, +} from '../../helpers/teams.helpers' + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const TEAM_WITH_MEMBERS = { + id: TEAM_ID, + name: 'Dream Team', + members: [ + { + ...MEMBER_OWNER, + user: { id: USER_ID, name: 'Alice', email: 'alice@example.com' }, + }, + { + ...MEMBER_ADMIN, + user: { id: MEMBER_ADMIN.userId, name: 'Bob', email: 'bob@example.com' }, + }, + { + ...MEMBER_PLAIN, + user: { id: MEMBER_PLAIN.userId, name: 'Charlie', email: 'charlie@example.com' }, + }, + ], +} + +// ── suite ───────────────────────────────────────────────────────────────────── +describe('TeamMembersService', () => { + let service: TeamMembersService + let prisma: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + prisma = createPrismaMock() + service = new TeamMembersService(prisma) + }) + + // ── getMembers ──────────────────────────────────────────────────────────── + describe('getMembers', () => { + it('должен вернуть список участников команды', async () => { + prisma.team.findUnique.mockResolvedValue(TEAM_WITH_MEMBERS) + + const result = await service.getMembers(TEAM_ID, USER_ID) + + expect(prisma.team.findUnique).toHaveBeenCalledOnce() + expect(result).toEqual(TEAM_WITH_MEMBERS.members) + }) + + it('должен выбросить 404 если команда не найдена', async () => { + prisma.team.findUnique.mockResolvedValue(null) + + await expect(service.getMembers(TEAM_ID, USER_ID)).rejects.toThrow( + NotFoundException, + ) + }) + + it('должен выбросить 403 если пользователь не участник команды', async () => { + prisma.team.findUnique.mockResolvedValue(TEAM_WITH_MEMBERS) + + await expect(service.getMembers(TEAM_ID, 'stranger-id')).rejects.toThrow( + ForbiddenException, + ) + }) + }) + + // ── changeRole ──────────────────────────────────────────────────────────── + describe('changeRole', () => { + it('OWNER успешно меняет роль MEMBER', async () => { + const updated = { + ...MEMBER_PLAIN, + role: 'ADMIN' as const, + user: { id: MEMBER_PLAIN.userId, name: 'Charlie', email: 'charlie@example.com' }, + } + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_OWNER) // actor + .mockResolvedValueOnce(MEMBER_PLAIN) // target + prisma.teamMember.update.mockResolvedValue(updated) + + const result = await service.changeRole(TEAM_ID, USER_ID, MEMBER_PLAIN.userId, { + role: 'ADMIN', + }) + + expect(prisma.teamMember.update).toHaveBeenCalledOnce() + expect(result).toEqual(updated) + }) + + it('ADMIN успешно меняет роль MEMBER', async () => { + const updated = { + ...MEMBER_PLAIN, + role: 'ADMIN' as const, + user: { id: MEMBER_PLAIN.userId, name: 'Charlie', email: 'charlie@example.com' }, + } + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_ADMIN) // actor + .mockResolvedValueOnce(MEMBER_PLAIN) // target + prisma.teamMember.update.mockResolvedValue(updated) + + const result = await service.changeRole( + TEAM_ID, + MEMBER_ADMIN.userId, + MEMBER_PLAIN.userId, + { role: 'ADMIN' }, + ) + + expect(prisma.teamMember.update).toHaveBeenCalledOnce() + expect(result).toEqual(updated) + }) + + it('должен выбросить 403 если actor пытается изменить свою роль', async () => { + await expect( + service.changeRole(TEAM_ID, USER_ID, USER_ID, { role: 'ADMIN' }), + ).rejects.toThrow(ForbiddenException) + }) + + it('должен выбросить 403 если target является OWNER', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_ADMIN) // actor + .mockResolvedValueOnce(MEMBER_OWNER) // target is OWNER + + await expect( + service.changeRole(TEAM_ID, MEMBER_ADMIN.userId, USER_ID, { role: 'MEMBER' }), + ).rejects.toThrow(ForbiddenException) + }) + + it('должен выбросить 403 если MEMBER пытается менять роли', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_PLAIN) // actor is MEMBER + .mockResolvedValueOnce(MEMBER_ADMIN) // target + + await expect( + service.changeRole(TEAM_ID, MEMBER_PLAIN.userId, MEMBER_ADMIN.userId, { + role: 'MEMBER', + }), + ).rejects.toThrow(ForbiddenException) + }) + + it('должен выбросить 404 если target не в команде', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_OWNER) // actor + .mockResolvedValueOnce(null) // target not found + + await expect( + service.changeRole(TEAM_ID, USER_ID, 'unknown-id', { role: 'MEMBER' }), + ).rejects.toThrow(NotFoundException) + }) + }) + + // ── removeMember ────────────────────────────────────────────────────────── + describe('removeMember', () => { + it('OWNER удаляет MEMBER', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_OWNER) // actor + .mockResolvedValueOnce(MEMBER_PLAIN) // target + prisma.teamMember.delete.mockResolvedValue(MEMBER_PLAIN) + + const result = await service.removeMember(TEAM_ID, USER_ID, MEMBER_PLAIN.userId) + + expect(prisma.teamMember.delete).toHaveBeenCalledOnce() + expect(result).toEqual({ message: 'Участник успешно исключён', success: true }) + }) + + it('ADMIN удаляет MEMBER', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_ADMIN) // actor + .mockResolvedValueOnce(MEMBER_PLAIN) // target + prisma.teamMember.delete.mockResolvedValue(MEMBER_PLAIN) + + const result = await service.removeMember( + TEAM_ID, + MEMBER_ADMIN.userId, + MEMBER_PLAIN.userId, + ) + + expect(prisma.teamMember.delete).toHaveBeenCalledOnce() + expect(result).toEqual({ message: 'Участник успешно исключён', success: true }) + }) + + it('MEMBER покидает команду самостоятельно (self-leave)', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_PLAIN) // actor = target + .mockResolvedValueOnce(MEMBER_PLAIN) // target + prisma.teamMember.delete.mockResolvedValue(MEMBER_PLAIN) + + const result = await service.removeMember( + TEAM_ID, + MEMBER_PLAIN.userId, + MEMBER_PLAIN.userId, + ) + + expect(prisma.teamMember.delete).toHaveBeenCalledOnce() + expect(result).toEqual({ message: 'Вы покинули команду', success: true }) + }) + + it('ADMIN покидает команду самостоятельно (self-leave)', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_ADMIN) // actor = target + .mockResolvedValueOnce(MEMBER_ADMIN) // target + prisma.teamMember.delete.mockResolvedValue(MEMBER_ADMIN) + + const result = await service.removeMember( + TEAM_ID, + MEMBER_ADMIN.userId, + MEMBER_ADMIN.userId, + ) + + expect(prisma.teamMember.delete).toHaveBeenCalledOnce() + expect(result).toEqual({ message: 'Вы покинули команду', success: true }) + }) + + it('должен выбросить 403 при попытке удалить OWNER', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_ADMIN) // actor + .mockResolvedValueOnce(MEMBER_OWNER) // target is OWNER + + await expect( + service.removeMember(TEAM_ID, MEMBER_ADMIN.userId, USER_ID), + ).rejects.toThrow(ForbiddenException) + }) + + it('должен выбросить 403 если MEMBER пытается удалить другого участника', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_PLAIN) // actor is MEMBER + .mockResolvedValueOnce(MEMBER_ADMIN) // target + + await expect( + service.removeMember(TEAM_ID, MEMBER_PLAIN.userId, MEMBER_ADMIN.userId), + ).rejects.toThrow(ForbiddenException) + }) + + it('должен выбросить 403 если ADMIN пытается удалить другого ADMIN', async () => { + const anotherAdmin = { ...MEMBER_ADMIN, id: 'member-id-4', userId: 'user-id-4' } + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_ADMIN) // actor is ADMIN + .mockResolvedValueOnce(anotherAdmin) // target is also ADMIN + + await expect( + service.removeMember(TEAM_ID, MEMBER_ADMIN.userId, anotherAdmin.userId), + ).rejects.toThrow(ForbiddenException) + }) + + it('должен выбросить 404 если target не в команде', async () => { + prisma.teamMember.findUnique + .mockResolvedValueOnce(MEMBER_OWNER) // actor + .mockResolvedValueOnce(null) // target not found + + await expect(service.removeMember(TEAM_ID, USER_ID, 'unknown-id')).rejects.toThrow( + NotFoundException, + ) + }) + }) +})