fix(api,web): separate workspace context from auth session (#534)
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>
This commit is contained in:
229
apps/api/src/workspaces/workspaces.service.spec.ts
Normal file
229
apps/api/src/workspaces/workspaces.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user