Files
stack/apps/api/src/workspaces/workspaces.service.spec.ts
Jason Woltje 8388d49786
Some checks are pending
ci/woodpecker/push/api Pipeline is running
feat(api): add workspace member management endpoints (#556)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 18:01:05 +00:00

517 lines
17 KiB
TypeScript

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>(WorkspacesService);
vi.clearAllMocks();
mockPrismaService.$transaction.mockImplementation(
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) =>
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<unknown>) => {
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<unknown>) => {
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<unknown>) => {
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);
});
});
});