import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { WorkspacesService } from "./workspaces.service"; import { PrismaService } from "../prisma/prisma.service"; import { WorkspaceMemberRole } from "@prisma/client"; import { BadRequestException, ConflictException, ForbiddenException, NotFoundException, } from "@nestjs/common"; describe("WorkspacesService", () => { let service: WorkspacesService; const mockUserId = "550e8400-e29b-41d4-a716-446655440001"; const mockAdminUserId = "550e8400-e29b-41d4-a716-446655440010"; const mockMemberUserId = "550e8400-e29b-41d4-a716-446655440011"; const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440002"; const mockWorkspace = { id: mockWorkspaceId, name: "Test Workspace", ownerId: mockUserId, settings: {}, matrixRoomId: null, createdAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01"), }; const mockMembership = { workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), workspace: { id: mockWorkspaceId, name: "Test Workspace", ownerId: mockUserId, createdAt: new Date("2026-01-01"), }, }; const mockPrismaService = { workspaceMember: { findMany: vi.fn(), findUnique: vi.fn(), count: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), }, workspace: { create: vi.fn(), }, user: { findUnique: vi.fn(), }, $transaction: vi.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ WorkspacesService, { provide: PrismaService, useValue: mockPrismaService, }, ], }).compile(); service = module.get(WorkspacesService); vi.clearAllMocks(); mockPrismaService.$transaction.mockImplementation( async (fn: (tx: typeof mockPrismaService) => Promise) => fn(mockPrismaService as unknown as typeof mockPrismaService) ); }); describe("getUserWorkspaces", () => { it("should return all workspaces user is a member of", async () => { mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]); const result = await service.getUserWorkspaces(mockUserId); expect(result).toEqual([ { id: mockWorkspaceId, name: "Test Workspace", ownerId: mockUserId, role: WorkspaceMemberRole.OWNER, createdAt: mockMembership.workspace.createdAt, }, ]); expect(mockPrismaService.workspaceMember.findMany).toHaveBeenCalledWith({ where: { userId: mockUserId }, include: { workspace: { select: { id: true, name: true, ownerId: true, createdAt: true }, }, }, orderBy: { joinedAt: "asc" }, }); }); it("should return multiple workspaces ordered by joinedAt", async () => { const secondWorkspace = { ...mockMembership, workspaceId: "ws-2", role: WorkspaceMemberRole.MEMBER, joinedAt: new Date("2026-02-01"), workspace: { id: "ws-2", name: "Second Workspace", ownerId: "other-user", createdAt: new Date("2026-02-01"), }, }; mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([ mockMembership, secondWorkspace, ]); const result = await service.getUserWorkspaces(mockUserId); expect(result).toHaveLength(2); expect(result[0].id).toBe(mockWorkspaceId); expect(result[1].id).toBe("ws-2"); expect(result[1].role).toBe(WorkspaceMemberRole.MEMBER); }); it("should auto-provision a default workspace when user has no memberships", async () => { mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]); mockPrismaService.$transaction.mockImplementationOnce( async (fn: (tx: typeof mockPrismaService) => Promise) => { const txMock = { workspaceMember: { findFirst: vi.fn().mockResolvedValueOnce(null), create: vi.fn().mockResolvedValueOnce({}), }, workspace: { create: vi.fn().mockResolvedValueOnce(mockWorkspace), }, }; return fn(txMock as unknown as typeof mockPrismaService); } ); const result = await service.getUserWorkspaces(mockUserId); expect(result).toHaveLength(1); expect(result[0].name).toBe("Test Workspace"); expect(result[0].role).toBe(WorkspaceMemberRole.OWNER); expect(mockPrismaService.$transaction).toHaveBeenCalledTimes(1); }); it("should return existing workspace if one was created between initial check and transaction", async () => { // Simulates a race condition: initial findMany returns [], but inside the // transaction another request already created a workspace. mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]); mockPrismaService.$transaction.mockImplementationOnce( async (fn: (tx: typeof mockPrismaService) => Promise) => { const txMock = { workspaceMember: { findFirst: vi.fn().mockResolvedValueOnce(mockMembership), }, workspace: { create: vi.fn(), }, }; return fn(txMock as unknown as typeof mockPrismaService); } ); const result = await service.getUserWorkspaces(mockUserId); expect(result).toHaveLength(1); expect(result[0].id).toBe(mockWorkspaceId); expect(result[0].name).toBe("Test Workspace"); }); it("should create workspace with correct data during auto-provisioning", async () => { mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]); let capturedWorkspaceData: unknown; let capturedMemberData: unknown; mockPrismaService.$transaction.mockImplementationOnce( async (fn: (tx: typeof mockPrismaService) => Promise) => { const txMock = { workspaceMember: { findFirst: vi.fn().mockResolvedValueOnce(null), create: vi.fn().mockImplementation((args: unknown) => { capturedMemberData = args; return {}; }), }, workspace: { create: vi.fn().mockImplementation((args: unknown) => { capturedWorkspaceData = args; return mockWorkspace; }), }, }; return fn(txMock as unknown as typeof mockPrismaService); } ); await service.getUserWorkspaces(mockUserId); expect(capturedWorkspaceData).toEqual({ data: { name: "My Workspace", ownerId: mockUserId, settings: {}, }, }); expect(capturedMemberData).toEqual({ data: { workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, }, }); }); it("should not auto-provision when user already has workspaces", async () => { mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]); await service.getUserWorkspaces(mockUserId); expect(mockPrismaService.$transaction).not.toHaveBeenCalled(); }); it("should propagate database errors", async () => { mockPrismaService.workspaceMember.findMany.mockRejectedValueOnce( new Error("Database connection failed") ); await expect(service.getUserWorkspaces(mockUserId)).rejects.toThrow( "Database connection failed" ); }); }); describe("addMember", () => { const addMemberDto = { userId: mockMemberUserId, role: WorkspaceMemberRole.MEMBER, }; it("should add a new member to the workspace", async () => { const createdMembership = { workspaceId: mockWorkspaceId, userId: mockMemberUserId, role: WorkspaceMemberRole.MEMBER, joinedAt: new Date("2026-02-02"), }; mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId }); mockPrismaService.workspaceMember.findUnique .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockAdminUserId, role: WorkspaceMemberRole.ADMIN, joinedAt: new Date("2026-01-01"), }) .mockResolvedValueOnce(null); mockPrismaService.workspaceMember.create.mockResolvedValueOnce(createdMembership); const result = await service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto); expect(result).toEqual(createdMembership); expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({ data: { workspaceId: mockWorkspaceId, userId: mockMemberUserId, role: WorkspaceMemberRole.MEMBER, }, }); }); it("should throw NotFoundException when user does not exist", async () => { mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockAdminUserId, role: WorkspaceMemberRole.ADMIN, joinedAt: new Date("2026-01-01"), }); mockPrismaService.user.findUnique.mockResolvedValueOnce(null); await expect( service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto) ).rejects.toThrow(NotFoundException); }); it("should throw ConflictException when user is already a member", async () => { mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockAdminUserId, role: WorkspaceMemberRole.ADMIN, joinedAt: new Date("2026-01-01"), }); mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId }); mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockMemberUserId, role: WorkspaceMemberRole.MEMBER, joinedAt: new Date("2026-01-02"), }); await expect( service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto) ).rejects.toThrow(ConflictException); }); it("should throw ForbiddenException when admin tries to assign OWNER role", async () => { mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockAdminUserId, role: WorkspaceMemberRole.ADMIN, joinedAt: new Date("2026-01-01"), }); await expect( service.addMember(mockWorkspaceId, mockAdminUserId, { userId: mockMemberUserId, role: WorkspaceMemberRole.OWNER, }) ).rejects.toThrow(ForbiddenException); }); }); describe("updateMemberRole", () => { it("should update a member role", async () => { const updatedMembership = { workspaceId: mockWorkspaceId, userId: mockMemberUserId, role: WorkspaceMemberRole.ADMIN, joinedAt: new Date("2026-01-02"), }; mockPrismaService.workspaceMember.findUnique .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }) .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockMemberUserId, role: WorkspaceMemberRole.MEMBER, joinedAt: new Date("2026-01-02"), }); mockPrismaService.workspaceMember.update.mockResolvedValueOnce(updatedMembership); const result = await service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, { role: WorkspaceMemberRole.ADMIN, }); expect(result).toEqual(updatedMembership); expect(mockPrismaService.workspaceMember.update).toHaveBeenCalledWith({ where: { workspaceId_userId: { workspaceId: mockWorkspaceId, userId: mockMemberUserId, }, }, data: { role: WorkspaceMemberRole.ADMIN, }, }); }); it("should throw NotFoundException when target member does not exist", async () => { mockPrismaService.workspaceMember.findUnique .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }) .mockResolvedValueOnce(null); await expect( service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, { role: WorkspaceMemberRole.ADMIN, }) ).rejects.toThrow(NotFoundException); }); it("should throw BadRequestException when sole owner attempts self-demotion", async () => { mockPrismaService.workspaceMember.findUnique .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }) .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }); mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1); await expect( service.updateMemberRole(mockWorkspaceId, mockUserId, mockUserId, { role: WorkspaceMemberRole.ADMIN, }) ).rejects.toThrow(BadRequestException); }); it("should throw ForbiddenException when actor tries to change role of higher-ranked member", async () => { mockPrismaService.workspaceMember.findUnique .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockAdminUserId, role: WorkspaceMemberRole.ADMIN, joinedAt: new Date("2026-01-01"), }) .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }); await expect( service.updateMemberRole(mockWorkspaceId, mockAdminUserId, mockUserId, { role: WorkspaceMemberRole.MEMBER, }) ).rejects.toThrow(ForbiddenException); }); }); describe("removeMember", () => { it("should remove a workspace member", async () => { mockPrismaService.workspaceMember.findUnique .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }) .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockMemberUserId, role: WorkspaceMemberRole.MEMBER, joinedAt: new Date("2026-01-02"), }); mockPrismaService.workspaceMember.delete.mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockMemberUserId, role: WorkspaceMemberRole.MEMBER, joinedAt: new Date("2026-01-02"), }); await service.removeMember(mockWorkspaceId, mockUserId, mockMemberUserId); expect(mockPrismaService.workspaceMember.delete).toHaveBeenCalledWith({ where: { workspaceId_userId: { workspaceId: mockWorkspaceId, userId: mockMemberUserId, }, }, }); }); it("should throw BadRequestException when trying to remove the last owner", async () => { mockPrismaService.workspaceMember.findUnique .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }) .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }); mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1); await expect(service.removeMember(mockWorkspaceId, mockUserId, mockUserId)).rejects.toThrow( BadRequestException ); }); it("should throw ForbiddenException when admin attempts to remove an owner", async () => { mockPrismaService.workspaceMember.findUnique .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockAdminUserId, role: WorkspaceMemberRole.ADMIN, joinedAt: new Date("2026-01-01"), }) .mockResolvedValueOnce({ workspaceId: mockWorkspaceId, userId: mockUserId, role: WorkspaceMemberRole.OWNER, joinedAt: new Date("2026-01-01"), }); await expect( service.removeMember(mockWorkspaceId, mockAdminUserId, mockUserId) ).rejects.toThrow(ForbiddenException); }); }); });