feat(api): add workspace member management endpoints (#556)
Some checks are pending
ci/woodpecker/push/api Pipeline is running

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #556.
This commit is contained in:
2026-02-28 18:01:05 +00:00
committed by jason.woltje
parent 20f914ea85
commit 8388d49786
9 changed files with 718 additions and 8 deletions

View File

@@ -3,11 +3,19 @@ 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 = {
@@ -36,11 +44,18 @@ describe("WorkspacesService", () => {
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(),
};
@@ -58,6 +73,11 @@ describe("WorkspacesService", () => {
service = module.get<WorkspacesService>(WorkspacesService);
vi.clearAllMocks();
mockPrismaService.$transaction.mockImplementation(
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) =>
fn(mockPrismaService as unknown as typeof mockPrismaService)
);
});
describe("getUserWorkspaces", () => {
@@ -226,4 +246,271 @@ describe("WorkspacesService", () => {
);
});
});
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);
});
});
});