BetterAuth session responses contain only identity fields — workspace context (workspaceId, currentWorkspaceId) was never returned, causing "Workspace ID is required" on every guarded endpoint after login. Add GET /api/workspaces endpoint (AuthGuard only, no WorkspaceGuard) that returns user workspace memberships with auto-provisioning for new users. Frontend auth-context now fetches workspaces after session check and persists the default to localStorage. Race condition in auto-provisioning is guarded by re-querying inside the transaction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
230 lines
7.2 KiB
TypeScript
230 lines
7.2 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";
|
|
|
|
describe("WorkspacesService", () => {
|
|
let service: WorkspacesService;
|
|
|
|
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
|
|
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(),
|
|
create: vi.fn(),
|
|
},
|
|
workspace: {
|
|
create: 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();
|
|
});
|
|
|
|
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"
|
|
);
|
|
});
|
|
});
|
|
});
|